Skip to content

fix(deploy): Copy stacks without rsync when the job image has none - #899

Merged
stefanko-ch merged 2 commits into
mainfrom
fix/rsync-free-stack-sync
Sep 19, 2026
Merged

stefanko-ch merged 2 commits into
mainfrom
fix/rsync-free-stack-sync

Conversation

@stefanko-ch

@stefanko-ch stefanko-ch commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Closes #897.

What broke

A spin-up on a Forgejo runner got through the whole server build and stopped in pre-bootstrap:

✅ Cloudflare Tunnel installed on attempt 1
  ✗ stack-sync: failed — transport (FileNotFoundError)

rsync is not in the runner's job image. Measured against the image the runner declares:

Present Missing
ssh, scp, tar 1.34, gzip, openssl, ssh-keygen rsync

Python raises FileNotFoundError for a missing executable. It is an OSError, so it landed in the phase's transport arm and was reported as a transport failure — which reads like a network fault and is a missing binary. GitHub's ubuntu-latest ships rsync, so this cannot reproduce upstream.

Approach

_remote.push_directory picks the transport at call time: rsync when it is on PATH — every GitHub runner, so that path is byte-for-byte unchanged — and tar over ssh when it is not.

Why not install rsync

That option was measured rather than argued, and it works: the rsync and libpopt0 packages extract without root, and with LD_LIBRARY_PATH the image has a working rsync 3.2.7.

It was rejected because the pin rots. The project installs pinned tools with pinned checksums (install-tool.sh), and Debian removes old point releases from its pool — the first URL tried, rsync_3.2.7-1+deb12u3, already 404s; only deb12u6 remains. An installer whose artifact disappears on Debian's schedule produces a deploy failure months later that looks unrelated to the commit that caused it. That is a worse failure than the one being fixed.

snapshot.debian.org keeps every version forever and would fix the rot, at the cost of putting a slow, rate-limited archive in the path of every deploy. One problem traded for another.

How the fallback is built

tar -C <local> -cf <archive> .
ssh <host> "set -eu; mkdir -p -- <path>[; find <path> -mindepth 1 -delete]; tar -C <path> -xpf - --no-same-owner"  < archive

Four deliberate choices:

  • An archive file, not tar | ssh. A pipeline hides the writer's exit status behind the reader's, which is the shape of fix(ci): install-opentofu.sh fails at random with exit 141 — tofu version | head under pipefail #883. Here each half is a separate call whose status is checked on its own.
  • set -eu, not set -euo pipefail. ssh host "<cmd>" runs in the remote account's login shell, which is not guaranteed to be bash; dash answers -o pipefail with "Illegal option", exactly as a container job did in fix(ci): Install every lifecycle tool the job image may lack #886. There is no pipeline here for pipefail to protect.
  • --no-same-owner. The numeric uid of whatever account the CI job runs as means nothing on the server, and every destination here is root-owned service configuration. Modes are preserved, which is what the 0600 Infisical payload needs.
  • The archive keeps mkstemp's 0600 and is removed in a finally, including when ssh fails — one caller pushes secret values through it.

SSHClient.rsync_to carried its own copy of the rsync invocation, identical and identically broken. It now delegates, so the codebase has one transport rather than two.

The diagnostic

_transport_detail no longer calls a missing program a transport failure:

executable not found: rsync (not installed in this job image)

A missing binary and a dropped connection need different fixes, and the old wording sent the reader to the network.

Guards

tests/unit/test_push_directory.py, 14 tests. The tar path runs for real: a fake ssh on PATH executes the remote command locally, so the assertions are about which files arrived and with which modes — the argument list was never the thing that broke.

Covered: dotfiles (every stack carries a rendered .env), 0600 modes, a destination created when missing, contents-not-the-directory, delete=True clearing stale files including hidden ones, no-delete keeping server-generated files, a failing ssh raising CalledProcessError with stderr (which stack_sync prints per service), a refused host-without-path, no archive left behind, a POSIX remote script, and a quoted remote path.

Eight mutations, each caught by the intended test: no fallback, no mkdir -p, no find -delete, dotfiles excluded from the archive, no host:path validation, no cleanup of the archive, pipefail back in the remote script, and an unquoted destination.

One of them was not caught at first, and the test was wrong rather than the code. test_no_archive_is_left_behind set TMPDIR, but tempfile resolves and caches its directory on first use, so the assertion inspected a directory nothing had ever written to and passed for the wrong reason. It now patches tempfile.tempdir.

Two existing test files would have passed here and failed on the target machine. test_remote.py and test_ssh.py pinned the rsync argv but not the transport, so on a job image without rsync they would fail — on the very machine the fallback exists for. Each now takes an explicit rsync_installed fixture.

Measured end to end

The full unit suite passes with a PATH that contains no rsync at all — 3643 passed — which is the environment a Conductor fork's CI actually runs in.

pytest tests/unit: 3643 passed. Pre-commit (ruff, mypy strict, shellcheck, actionlint): all hooks pass.

Not verified yet

A real spin-up on the Forgejo runner. The transport is exercised against a local fake ssh, not against the server, and the phase that failed is the one after the last verified step. The next Conductor lifecycle is what settles it.

Side finding, filed separately

A static sweep of every executable the deploy package spawns turned up one more that the job image lacks: htpasswd, whose docstring states that "every CI runner that runs this code has apache2-utils installed". It bites only when Filestash is enabled, and the fix is a dependency decision rather than a bug fix — filed as #898.

  9  ssh          present
  5  tofu         installed by install-opentofu.sh
  1  ssh-keygen   present
  1  scp          present
  1  tar          present
  1  htpasswd     MISSING

Local CodeRabbit round

Reviewed c04a6ba8: 1 finding, valid, fixed in f8135050.

  • Fixed (major): set -euo pipefail in the remote script makes it bash-only for no benefit. Now set -eu, with a guard that fails if pipefail returns.

An earlier attempt at the round could not run (rate limit, all included reviews used); this one did. f8135050 itself was not reviewed locally; this review covers it.

Summary by Sourcery

Support stack deployment from runner images without rsync while preserving existing synchronization behavior and improving failure diagnostics.

New Features:

  • Add automatic directory synchronization using rsync when available and tar over SSH otherwise.

Bug Fixes:

  • Allow deployments to copy stacks on runner images without rsync.
  • Report missing executables as job-image dependency issues instead of transport failures.

Enhancements:

  • Preserve directory contents, hidden files, file modes, deletion behavior, error reporting, and temporary archive cleanup across both transports.
  • Centralize directory-push behavior so SSH-based callers share the same transport implementation.

Tests:

  • Add coverage for both transports, archive cleanup, POSIX-compatible remote commands, path quoting, synchronization semantics, and missing-executable diagnostics.

Summary by CodeRabbit

  • New Features

    • Directory transfers now work with or without rsync, automatically selecting the available transport.
    • File permissions, hidden files, destination creation, and optional deletion of remote-only files are supported during transfers.
  • Bug Fixes

    • Missing executables are now reported clearly instead of being misidentified as transport failures.
  • Tests

    • Expanded coverage for fallback transfers, path validation, cleanup, permissions, and error handling.

A spin-up on a Forgejo runner got through the whole server build and
stopped in pre-bootstrap:

  ✗ stack-sync: failed — transport (FileNotFoundError)

`rsync` is not in the runner's job image. Measured against
`node:22-bookworm`: rsync missing, while ssh, scp, tar 1.34 and gzip are
there. Python raises FileNotFoundError for a missing executable, which is
an OSError, so it landed in the phase's transport arm and read like a
network fault.

`_remote.push_directory` now picks the transport at call time: rsync when
it is on PATH — every GitHub runner, so that path is untouched — and tar
over ssh when it is not.

Installing rsync instead was the other option and was measured rather than
argued. It works: the rsync and libpopt packages extracted without root,
plus LD_LIBRARY_PATH, give a working `rsync 3.2.7` in that image. It was
rejected because the pin rots — Debian removes old point releases from its
pool, and the URL tried first (`deb12u3`) already 404s while only `deb12u6`
remains. A pinned-and-verified installer whose URL disappears on Debian's
schedule is a worse failure than the one being fixed, because it arrives
months later and looks unrelated.

The fallback writes an archive and sends it, rather than piping `tar | ssh`:
a pipeline hides the writer's status behind the reader's, which is the
shape of #883. Each half is checked on its own. The archive keeps mkstemp's
0600 — one caller pushes Infisical secret values — and is removed in a
`finally`, including when ssh fails. Extraction uses --no-same-owner: the
numeric uid of whatever account the CI job runs as means nothing on the
server, and every destination here is root-owned service configuration.

`SSHClient.rsync_to` had its own copy of the rsync invocation, identical
and identically broken. It now delegates, so there is one transport in the
codebase rather than two.

`_transport_detail` no longer calls a missing program a transport failure.
It reports `executable not found: rsync (not installed in this job image)`,
which is the sentence that would have saved the hour this cost.

Guards in tests/unit/test_push_directory.py, 12 of them, and the tar path
is exercised for real: a fake `ssh` on PATH runs the remote command
locally, so the assertions are about which files arrived and with which
modes. Dotfiles (every stack carries a rendered .env), 0600 modes, a
created destination, delete-clears-stale, no-delete-keeps-generated-files,
a failing ssh raising CalledProcessError with stderr, and a refused
`host`-without-path.

Six mutations, each caught: no fallback, no mkdir, no delete, dotfiles
excluded, no host:path validation, and no cleanup of the archive.

The last one was NOT caught at first, and the test was wrong rather than
the code: it set TMPDIR, but tempfile resolves and caches its directory on
first use, so the assertion inspected a directory nothing had written to.
It now patches `tempfile.tempdir` and fails when the cleanup goes.

The existing rsync tests in test_remote.py and test_ssh.py pinned the
argv but not the transport, so they would have passed on GitHub and failed
on a job image without rsync — the very machine this is for. Each now
takes an explicit `rsync_installed` fixture.

Measured end to end: the full unit suite passes with a PATH that has no
rsync in it at all (3641 passed), which is the environment a Conductor
fork's CI actually runs in.

Closes #897
From the local CodeRabbit round on c04a6ba, and valid.

The remote half of the tar transport opened with `set -euo pipefail`.
`ssh host "<cmd>"` runs the command in the remote account's login shell,
which is not guaranteed to be bash: dash answers `-o pipefail` with
"Illegal option", which is exactly how a container job failed in #886. It
works on Ubuntu, where root's shell is bash, and would have broken on the
first server whose shell is not — for nothing, because the script has no
pipeline for pipefail to protect. Three commands, no pipes.

Now `set -eu`, which is POSIX.

Two guards, each mutation-tested with a recording fake ssh that captures
the command string: putting `pipefail` back fails
test_the_remote_script_runs_in_a_posix_shell, and dropping the
`shlex.quote` on the destination fails test_the_remote_path_is_quoted —
the path reaches a remote shell as part of a command string, so the
quoting is load-bearing rather than tidy.

Refs #897

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @stefanko-ch, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 hours and 9 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: stefanko-ch/Nexus-Stack/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 7d473fe3-5c7a-4f82-9b6a-99b3c6b24b78

📥 Commits

Reviewing files that changed from the base of the PR and between 5c6e93d and f813505.

📒 Files selected for processing (6)
  • src/nexus_deploy/_remote.py
  • src/nexus_deploy/orchestrator.py
  • src/nexus_deploy/ssh.py
  • tests/unit/test_push_directory.py
  • tests/unit/test_remote.py
  • tests/unit/test_ssh.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Remote transport fallback

Layer / File(s) Summary
Transport selection and tar fallback
src/nexus_deploy/_remote.py
push_directory selects rsync when available. Otherwise, it archives directory contents and sends them over SSH. The fallback supports deletion, path validation, quoting, and temporary archive cleanup.
SSH delegation and executable diagnostics
src/nexus_deploy/ssh.py, src/nexus_deploy/orchestrator.py
SSHClient.rsync_to delegates to push_directory. Missing executables now produce a dedicated diagnostic.
Fallback and selection validation
tests/unit/test_push_directory.py, tests/unit/test_remote.py, tests/unit/test_ssh.py
Tests cover transport selection, file copying, modes, deletion, failures, shell commands, path quoting, cleanup, and missing-executable diagnostics.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant push_directory
  participant rsync
  participant ssh
  participant RemotePath
  Caller->>push_directory: upload directory
  alt rsync is available
    push_directory->>rsync: copy directory contents
  else rsync is unavailable
    push_directory->>ssh: run remote extraction script
    ssh->>RemotePath: create, clear, and extract contents
  end
  push_directory-->>Caller: return CompletedProcess
Loading

Merge Risk: ⚪ Minimal · up to f8135

No concrete merge-blocking risk remains; failed uploads may leave temporary staging incomplete but do not clear deployed service directories.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 40 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: stack copying now works when the job image does not include rsync.
Linked Issues check ✅ Passed Issue #897 requires stack synchronization without rsync, preservation of the existing rsync path, clear missing-executable diagnostics, safe and complete fallback behavior, and automated coverage.…
Out of Scope Changes check ✅ Passed The changes stay within Issue #897. The transport consolidation in src/nexus_deploy/ssh.py, diagnostic change in src/nexus_deploy/orchestrator.py, shared fallback implementation in `src/nexus_depl…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

Directory pushes now use the existing rsync path when available and transparently fall back to a POSIX-compatible tar archive transferred over SSH when rsync is absent, while preserving deletion, permissions, error propagation, and cleanup semantics. Missing executables receive actionable diagnostics, duplicated SSH push logic is removed, and the transport behavior is covered with real fallback-path tests plus host-independent existing tests.

Sequence diagram for directory push transport selection

sequenceDiagram
    participant Caller
    participant Remote as _remote.push_directory
    participant PATH
    participant Rsync as _rsync_push
    participant Tar as _tar_push
    participant SSH as ssh

    Caller->>Remote: push_directory(local, remote, delete)
    Remote->>PATH: shutil.which(rsync)
    alt rsync available
        Remote->>Rsync: _rsync_push(local, remote, delete)
        Rsync->>Rsync: subprocess.run(rsync)
        Rsync-->>Caller: CompletedProcess
    else rsync absent
        Remote->>Tar: _tar_push(local, remote, delete)
        Tar->>Tar: tempfile.mkstemp()
        Tar->>Tar: subprocess.run(tar -C local -cf archive .)
        Tar->>SSH: subprocess.run(ssh host script, archive)
        SSH-->>Tar: CompletedProcess
        Tar->>Tar: archive.unlink()
        Tar-->>Caller: CompletedProcess
    end
Loading

Flow diagram for missing executable diagnostics

flowchart TD
    A[Executable invocation fails] --> B{FileNotFoundError with filename?}
    B -- Yes --> C[executable not found: program not installed in this job image]
    B -- No --> D[Existing transport error detail]
Loading

File-Level Changes

Change Details Files
Add an automatic rsync-to-tar-over-SSH transport fallback for directory pushes.
  • Select rsync at call time when available, preserving the existing invocation.
  • Create a 0600 temporary tar archive and stream it through SSH for extraction.
  • Create missing destinations, optionally delete stale contents including dotfiles, preserve modes, quote remote paths, and clean up archives on all outcomes.
  • Validate the remote host/path format and propagate subprocess failures with stderr.
src/nexus_deploy/_remote.py
Consolidate directory-push behavior behind the shared transport implementation.
  • Make SSHClient delegate its historical rsync_to method to push_directory.
  • Remove the duplicated rsync subprocess construction.
src/nexus_deploy/ssh.py
Improve diagnostics for missing executables.
  • Classify FileNotFoundError separately from network transport failures.
  • Report the missing executable and identify the job image as the likely cause.
src/nexus_deploy/orchestrator.py
Add integration-style coverage for the fallback and stabilize transport-specific existing tests.
  • Exercise the tar path with fake SSH and verify contents, dotfiles, modes, deletion semantics, destination creation, errors, cleanup, POSIX-safe scripts, and path quoting.
  • Verify rsync remains selected when installed.
  • Pin legacy argv tests to the rsync transport so they are independent of the host image.
tests/unit/test_push_directory.py
tests/unit/test_remote.py
tests/unit/test_ssh.py

Assessment against linked issues

Issue Objective Addressed Explanation
#897 Ensure stack synchronization works when the CI job image does not contain rsync. ✅
#897 Preserve the existing rsync behavior when rsync is available while providing a reliable alternative transport for directory contents, including deletion semantics, dotfiles, file modes, destination creation, cleanup, and error propagation. ✅
#897 Improve diagnostics so a missing executable is reported as a missing job-image dependency rather than as a generic transport failure. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

Copy link
Copy Markdown
Contributor

coverage

Coverage report — nexus_deploy
FileStmtsMissCoverMissing
__init__.py50100% 
_remote.py420100% 
cli.py40100% 
compose_restart.py400100% 
compose_runner.py880100% 
config.py1810100% 
firewall.py2060100% 
forgejo.py5985590%783–784, 789, 812–813, 825–826, 862–863, 875–876, 894–895, 920–921, 943–944, 955–956, 1011–1012, 1020–1021, 1026, 1032–1033, 1057–1058, 1091–1092, 1095, 1126–1127, 1168–1169, 1174–1175, 1215–1216, 1247–1248, 1271–1272, 1277–1278, 1377–1378, 1383–1384, 1860, 1864, 1885, 1913–1914, 2001
forgejo_runner.py47197%228
hetzner_capacity.py1720100% 
hetzner_snapshot.py2020100% 
infisical.py2220100% 
kestra.py177398%227, 441, 802
orchestrator.py6867788%205, 504–505, 517, 618, 810, 822, 992–993, 998–999, 1031–1033, 1042, 1047–1049, 1060, 1097–1098, 1103–1104, 1124, 1159–1160, 1165–1166, 1174, 1199–1200, 1208, 1279–1280, 1285–1286, 1338–1339, 1344–1345, 1596, 1599, 1669, 1675–1676, 1681–1682, 1716, 1840–1841, 1846–1847, 1896–1897, 1902–1903, 1962, 1977, 2034, 2039–2040, 2045–2046, 2053, 2059, 2234, 2241, 2253–2254, 2259–2260, 2266, 2272, 2356–2357, 2378–2379
pg_preflight.py191199%214
pipeline.py2361394%166–167, 351, 389, 470, 492, 587–588, 633–634, 724–725, 772
r2_tokens.py113298%87, 150
s3_persistence.py200199%315
s3_restore.py1030100% 
secret_sync.py990100% 
seeder.py980100% 
service_env.py5513394%2072, 2074–2076, 2084–2085, 2660–2663, 2668–2674, 2741–2745, 2761–2765, 2789, 2791, 2813–2814, 2821, 2946
services.py361199%2921
setup.py1651392%245, 315–318, 326, 330–335, 351
ssh.py520100% 
stack_sync.py960100% 
tfvars.py440100% 
tofu.py860100% 
workspace_coords.py1010100% 
TOTAL516620096% 

@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@stefanko-ch
stefanko-ch merged commit d083531 into main Sep 19, 2026
14 checks passed
@stefanko-ch
stefanko-ch deleted the fix/rsync-free-stack-sync branch September 19, 2026 09:00
stefanko-ch pushed a commit that referenced this pull request Sep 19, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.82.3](v0.82.2...v0.82.3)
(2026-09-19)


### 🐛 Bug Fixes

* **ci:** Keep the Forgejo Access service token across a teardown
([#895](#895))
([6784086](6784086))
* **deploy:** Copy stacks without rsync when the job image has none
([#899](#899))
([d083531](d083531))
* **service_env:** reject empty DOMAIN in render_all_env_files
([#863](#863))
([#867](#867))
([5c6e93d](5c6e93d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

## Summary by Sourcery

Release version 0.82.3 with fixes for CI token persistence, deployment
stack copying, and invalid domain configuration.

Bug Fixes:
- Preserve the Forgejo Access service token during teardown.
- Support copying deployment stacks when rsync is unavailable in the job
image.
- Reject empty DOMAIN values when rendering service environment files.

Chores:
- Release version 0.82.3 and update the changelog.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ci): stack-sync dies with 'transport (FileNotFoundError)' — rsync is not in the job image

1 participant