Skip to content

Delete ESS checkfiles on the server too, not just locally - #1000

Open
alongd wants to merge 1 commit into
mainfrom
remote-check-cleanup
Open

Delete ESS checkfiles on the server too, not just locally#1000
alongd wants to merge 1 commit into
mainfrom
remote-check-cleanup

Conversation

@alongd

@alongd alongd commented Aug 19, 2026

Copy link
Copy Markdown
Member

ARC uploads a Gaussian checkpoint as a wavefunction guess for each job and downloads it again afterwards. When a run terminates it deletes those files — but only on the local machine:

  • delete_check_files(project_directory) walks <project_directory>/calcs;
  • main.py calls it unconditionally at the end of a run, since keep_checks defaults to False;
  • no code path anywhere deletes anything on the remote server.

So the documented policy — "they usually take up lots of space and are not needed after ARC terminates" — is half-implemented, and every checkpoint ARC has ever uploaded is still sitting in the remote project directory.

Measured impact. On a quota-managed cluster, 82.3% of a fully characterised species' 61.4 MiB footprint is exactly this class of file — the next largest class is 5.6× smaller — and 6.35 GiB had silently accumulated against a 300 GB quota. Nothing downstream reads the remote copy: thermochemistry is parsed from the primary output log, and the local copy is the one reused as a guess.

What this does. Adds SSHClient.delete_check_files and a delete_remote_check_files helper, and routes the existing teardown through a new ARC.clean_check_files. Design constraints kept deliberately tight:

  • keep_checks=True keeps them everywhere, local and remote — the flag's meaning is unchanged;
  • deletion is scoped to *.chk under the run's own remote project directory, using the same path construction job/adapter.py already uses, and no-ops if that directory does not exist;
  • errors are logged and never raised. This runs after the science is done; an unreachable server at teardown is an inconvenience, not a reason to lose a run;
  • servers named local are skipped; nothing about uploads, the wavefunction-guess mechanism, or keep_checks's default changes.

Tests cover all three: deleted with keep_checks=False (asserting only .chk, and only under this project's directory), kept with keep_checks=True, and a run completing normally when the server call raises. Verified as a regression test rather than a tautology — removing just the remote call while leaving the helper in place makes test_check_files_are_deleted_locally_and_remotely fail.

Comment thread arc/main_test.py Fixed
Comment thread arc/job/ssh.py Outdated
"""
path = servers[server].get('path', '').lower()
path = os.path.join(path, servers[server]['un']) if path else ''
return os.path.join(path, 'runs', 'ARC_Projects', project)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

not alwyas running under 'runs', 'ARC_Projects'

Comment thread arc/job/ssh.py Outdated
command = f'chmod{recursive} {mode} {file_name}'
self._send_command_to_server(command, remote_path)

def delete_check_files(self, remote_path: str) -> None:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

what's the difference between this and arc.common.delete_check_files()?

ARC's `keep_checks` policy was only half implemented: `delete_check_files()`
walked the local `calcs` tree, and no code path ever removed the check files
ARC had uploaded to a server. They accumulate silently -- measured at 82% of a
fully characterised species' remote footprint, against a managed quota.

`ARC.clean_check_files()` now owns both sides of the policy: with `keep_checks`
false it deletes the local check files as before and then, on every non-local
server in `ess_settings`, deletes `*.chk` under that project's own remote
directory. `keep_checks=True` keeps them everywhere. Cleanup runs once ARC is
done with the science, so a server that cannot be reached at that point is
logged and skipped rather than failing the run.
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.47%. Comparing base (d033cbb) to head (90d4838).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1000      +/-   ##
==========================================
+ Coverage   64.38%   64.47%   +0.08%     
==========================================
  Files         119      119              
  Lines       39601    39627      +26     
  Branches    10269    10275       +6     
==========================================
+ Hits        25499    25549      +50     
+ Misses      11121    11089      -32     
- Partials     2981     2989       +8     
Flag Coverage Δ
functionaltests 64.47% <ø> (+0.08%) ⬆️
unittests 64.47% <ø> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

calvinp0 added a commit that referenced this pull request Aug 20, 2026
Every remote job opened its own paramiko Transport for upload, submission and
status polling. A TS search issuing ~100 guess optimizations against one server
opened ~100 connections, which is slow and trips per-user connection limits on
some clusters.

Add a process-global pool (arc/job/ssh_pool.py). A remote queue job leases one
client for the duration of its submission and reuses it for both file upload
and run; _open_or_borrow_ssh() prefers that leased client and otherwise borrows
from the pool. The borrower never closes a client it does not own.

The third case, a one-shot client for when the pool itself cannot lease one, is
real rather than nominal. Written as `try: return pool.borrow(server)` with an
`except` around it, it could never fire: borrow is a @contextmanager, so calling
it merely builds the generator, and the factory runs when the caller enters the
with-block -- outside the try meant to catch it. Rebuilt around
contextlib.ExitStack, the fallback covers exactly the lease, i.e. entering the
pool's context manager, and nothing else. In particular it does not cover the
caller's own work: a job that raises inside its with-block still raises, instead
of having its failure read as a broken pool and its body re-entered against a
fresh client.

Two pre-existing defects in the remote path that set_file_paths() builds are
fixed here as well, since a remote job's files are only reachable if that path
is. The server's configured 'path' was lowercased before use, which silently
rewrites any path with an uppercase component -- remote file systems are
case-sensitive, and /Home/Users is not /home/users. It is now used verbatim.
NOTE that this changes where files land for anyone whose configured 'path'
contains uppercase: their earlier runs are under the lowercased tree, and ARC
will now use the path as configured.

A server with no 'path' still gets a path relative to the SSH login directory,
because there is no absolute path ARC can know offline -- the home directory is
the server's to report, and set_file_paths() runs at job construction with no
connection open. Rooting it at '~' would be worse rather than better:
_send_command_to_server quotes the remote path with shlex.quote, so the remote
shell would take the tilde literally, and SFTP performs no tilde expansion at
all, so both would end up creating a directory actually named '~'. Relative,
which the remote shell and SFTP both resolve against the login directory, stays
correct. What changes is that it is no longer silent: such a server is reported
once per run, naming the setting, because an adapter that has to name a path
inside an input file cannot work with it.

Pooled clients also need closing when ARC exits. ssh_pool.py documented that
ARC.py's main() calls reset_default_pool(), but nothing did -- every caller was
a test, so pooled SSHClients were left to interpreter shutdown rather than
closed. Releasing them belongs to the run rather than to the command-line
entry point, so ARC.execute() does it in a finally: connections are torn down
on ctrl-C and on an exception as well as on a clean run, and a consumer that
drives ARC in process -- a library caller, a test, a pipe worker -- releases
them too, which an ARC.py-only hook could never do. ARC.py is unchanged.

The borrow itself is now one function, ssh_pool.borrow_ssh_client(), rather
than a method on JobAdapter. The pool's other callers are not adapters --
Scheduler.get_server_job_ids(), server troubleshooting, the ESS survey -- and
each would otherwise have grown its own copy of the lease-then-fall-back
dance. _open_or_borrow_ssh() keeps only what is adapter-specific, the
per-execute() leased client, and delegates the rest; the shared-client branch
is contextlib.nullcontext rather than a hand-rolled generator.

A pooled connection is held for the whole run and sits idle between polls, so
_default_factory sets a keepalive on the transport. An SSH daemon's
ClientAliveInterval or a firewall's idle timeout otherwise drops it silently:
the socket stays half-open, Transport.is_active() keeps reporting True, and the
pool's liveness check therefore hands out a handle whose first command hangs
until TCP gives up.

upload_file() and download_file() were the only SSHClient methods reaching for
self._sftp without @check_connections. That was harmless while every caller
opened its own client and used it immediately; with a client that has been
alive for hours it is not, since a dead transport surfaces as the transfer
failing rather than as a reconnect. Both are decorated now.

The pool is tested directly rather than only through a JobAdapter, so its own
contract -- reuse, reaping a dead client, retaining ownership on context exit,
idempotent close_all -- is stated by its tests instead of implied by adapter
behaviour. arc/job/ssh_pool_test.py drives SSHConnectionPool with a stub
factory and covers the cases adapter_test.py could not reach, namely that a
raising with-body leaves the pool reusable and that reset_default_pool() closes
pooled clients rather than just dropping the reference. The adapter-driven
integration tests stay with the adapter, which is what they actually exercise.

Two claims the pool's docstrings make were still untested, and the imports for
them were sitting unused in adapter_test.py: that a remote-queue execute() with
no pool injected borrows from the instance get_default_pool() returns, and that
reset_default_pool() -- ARC.py's exit hook -- closes the clients those jobs
opened and leaves a usable empty pool behind. Both are now asserted rather than
implied. The pool tearDowns also called set_default_pool(None), which drops the
reference without closing anything, so each test class leaked its stub clients
and contradicted the lifecycle ssh_pool.py documents; they call
reset_default_pool() instead.

Also two test-only cleanups CodeQL flags: two factory lambdas that only forwarded
their argument now pass the callable itself, and the "a raising with-body leaves
the pool usable" test uses assertRaises' callable form. Its context-manager form
made every statement after the block unreachable to a control-flow analyser,
because nothing in the CFG says assertRaises.__exit__ suppresses the exception.

Two of CodeQL's remaining alerts on this file are in adapter_test.py and are
the same two defects already fixed in ssh_pool_test.py. The assertRaises
context-manager form around a with-block whose last statement is `raise` makes
everything after the block unreachable to a control-flow analyser, since
nothing in the CFG says __exit__ suppresses the exception; both tests use the
callable form, and still assert what they did -- that the caller's own
exception reaches the caller, and that the pool is usable afterwards. And the
module both imported arc.job.adapter and imported names from it; the
module-alias form existed for two patch.object() calls, which are now
patch('arc.job.adapter.<name>'), so the file uses one import form. Dropping the
alias also removes a name that a class attribute in the same file shadowed.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: set_file_paths() splits the
project's remote directory out of the job's remote path as remote_project_path,
which is what the remote check file cleanup is scoped to.
calvinp0 added a commit that referenced this pull request Aug 20, 2026
…etry

Four related defects in SSHClient._connect(), all on the authentication path:

The configured 'key' was never used as an identity. connect() was called
without key_filename, while load_system_host_keys() was given the private key
path -- but that function expects a known_hosts database, so the private key
parsed as an empty host-key set and was never offered for authentication. A
server configured with an explicit 'key' therefore authenticated only by
accident, via an agent or a key already in a default location, and otherwise
failed in a way that looks like a server or a network problem. Load the system
host keys from their default location and pass the configured key to connect()
as key_filename.

Once the key is a real identity it must name a real, readable file: paramiko
raises when key_filename points at a path that does not exist. That would
exclude every setup which authenticates without a key file on the ARC machine,
and forwarding an agent into a container is exactly such a setup -- the one
where the key file deliberately never exists locally. key_filename=None is
valid and makes paramiko fall back to the agent and then to the default
~/.ssh/id_rsa, id_ecdsa and id_ed25519 paths (no id_dsa, which paramiko 4
dropped), so read 'key' with .get() and treat an empty string as unset. A
configured key is still forwarded unchanged. This does not extend to
~/.ssh/config: paramiko honours it only when the application constructs a
paramiko.SSHConfig itself, and ARC never does, so ProxyJump and bastion hosts
remain unsupported. _connect()'s docstring now says so, making that limitation
discoverable from the code rather than only from a failed connection.

AutoAddPolicy() accepted and stored any unseen host key without a word, so a
first connection and a machine-in-the-middle were indistinguishable and equally
silent. Default to LogAndAcceptHostKeyPolicy, which still connects but says so,
and add a per-server 'strict_host_key_checking' flag selecting
RejectUnknownHostKeyPolicy for deployments that want unknown hosts refused
outright. Warning is the default because rejecting would break any host not
already in known_hosts, which is a behaviour change users should opt into rather
than inherit.

Both policies are ARC's own, because paramiko's are unusable here. WarningPolicy
reports through warnings.warn(), and initialize_log() installs
filterwarnings(action='ignore', module='.*paramiko.*'), so its message reached
nobody -- measured, one warning before the filter and none after; ARC's policy
logs the host and the key's OpenSSH-style SHA256 fingerprint at warning level
and then accepts, leaving the connect-anyway behaviour exactly as it was.
RejectPolicy raises a plain SSHException, which is indistinguishable from a
transport failure without matching on its message text, so the strict policy
raises UnknownHostKeyError instead -- an ARC ServerError, and also a
paramiko.SSHException since that is what a missing host key policy is expected to
raise, so the retry loop catches it by type and ARC's existing server error
handling covers it everywhere else.

The retry around connect() used a bare 'except'. That also caught
KeyboardInterrupt and SystemExit -- so ctrl-C during a connection attempt
silently retried instead of aborting -- and it discarded the first exception,
meaning a bad key or a wrong username surfaced as whatever the retry happened
to raise. Catch (paramiko.SSHException, OSError), which covers the documented
"Error reading SSH protocol banner / Connection reset by peer" flake the retry
exists for, and log the first failure so its cause is not lost.

That retry is also the wrong answer to a permanent failure. connect() wraps
_connect() in a 1440 x 60 s loop and _connect() retries once, so a rejected key
or a wrong user name cost roughly 2880 authentication attempts spread over 24
hours against a server that was never going to accept them -- the traffic shape
that gets an account locked out. Classify first: AuthenticationException and
BadHostKeyException (both SSHException subclasses) and UnknownHostKeyError are
permanent, and are raised on the first attempt as a ServerError carrying the
paramiko exception as its cause, so connect()'s documented failure type is
unchanged. Everything else is transport-level and keeps the retry.

Also retry the remote-file existence check a few times before reporting a
missing download -- PBS/SGE epilogues can flush stdout/stderr a second or two
after qstat reports the job has left the queue, and the previous code warned on
that race -- and surface a hint on memory-related submission failures.

When the remote file really is absent, the local path is emptied rather than
left as it was. paramiko's SFTPClient.get() opens the local path 'wb' before it
reads the remote file, so main's fall-through created or truncated it on every
miss; returning early instead would leave a previous job's out.txt in place, and
JobAdapter._get_additional_job_info() reads whatever out.txt and err.txt are on
disk into additional_job_info, attributing an earlier job's server output to this
one. Truncating rather than deleting reproduces main's observable state exactly:
those readers gate on os.path.isfile and then read, so a zero-byte file and an
absent file are not the same thing to them, and changing which one they see is
not this change's business.

That miss is now reported at warning level rather than debug. A job with no
stdout at all is a strong signal of an abnormal failure -- the scheduler killed
it, the submit script never copied results back from scratch, or ARC is looking
at the wrong remote directory -- and ARC has no branch for that condition today:
adapter.py's `if self.additional_job_info:` simply does nothing when it is empty,
so this line is the only trace that it happened. Raising the level adds no
handling, it stops the event being invisible while handling is designed
separately. Two follow-ups belong to that separate work and are deliberately not
here: classifying why the output is absent (a remote directory listing, then
scheduler accounting for the job id, then a distinct no_output status), and a
guard that stops a run after N consecutive jobs produce no output.

The three-attempt existence check itself is unchanged -- same count, same one
second interval, same short-circuit as soon as the file appears -- and is now
pinned by tests, including one where the file shows up on the second attempt and
is downloaded normally.

The commented 'pharos' example in the settings file gave a known_hosts path as
'key'; with these semantics 'key' is the private key, never the host-key
database, so that is dropped.

Verified against a live server: both host-key policies connect successfully
with the host already in known_hosts, and _connect() opens SSH and SFTP
sessions and closes cleanly.

Warning is also the default because rejecting does not fail a scheduler once. A
refused host key fails every submission, status poll and download for that
server while the ARC driver stays alive, so the run continues and produces
nothing; the refusal raises the same exception class as a transport error, so it
also feeds the 24-hour connection retry loop and presents as a hang. Recovery
means stopping ARC, running ssh-keyscan and restarting, hours after the fact.
The rationale now lives in _connect()'s docstring rather than in line comments,
so it is discoverable from the code.

That default is only defensible if the risk it accepts is visible, so
check_servers_known_hosts() reports, at startup and before any calculation is
submitted, every configured server whose address has no host key in
~/.ssh/known_hosts -- naming the server, the address and the ssh-keyscan command
that seeds it. It is a warning, never fatal, consistent with the connection
policy itself, and it is entirely local: paramiko's HostKeys does the lookup, so
hashed (`ssh-keyscan -H`) and [host]:port entries match as OpenSSH matches them,
and nothing is resolved and no connection opened. The 'local' server, entries
with no address, and entries still carrying the repository's placeholder
*.host.edu address or <username> user name are skipped, since reporting them
would fire on every run made with the default settings. arc/main.py calls it
once logging is initialized and before determine_ess_settings(), so it covers a
plain local run and not only the container, where dockerfiles/arc_preflight.py
already checked a coarser version of the same thing.

The connect test helper caught BaseException, which CodeQL flags and which would
have reported an unrelated interpreter-level error as an expected one; it now
catches only the exception types the tests assert on.

A host key that is present and contradicted is a different event from one that
is absent, and it was reported as neither. BadHostKeyException was classified
as permanent and raised as a plain ServerError, so a machine-in-the-middle read
in the log exactly like a wrong password. connect() now raises
HostKeyMismatchError -- a ServerError, so nothing that handles server errors
changes, and a paramiko.SSHException like the exception it is raised from --
and reports it at the error level, naming the stored and the presented
fingerprints, because which of the two the reader recognises is what decides
whether the server was re-keyed or the session was intercepted, and giving the
ssh-keygen -R that replaces the stored key once the new one is confirmed.

That comparison needs the server, so it cannot be part of the startup report,
and get_servers_missing_host_keys() says so rather than leaving the reader to
assume otherwise. What a known_hosts file can be asked on its own is whether it
contradicts itself: get_servers_with_conflicting_host_keys() reports an address
with more than one entry of the same key type. A host legitimately has one key
per type and ssh-keyscan writes one line per type, so a second is either a
stale key left by a rebuilt server or one placed there to shadow the real one
-- and only the first matching entry is ever consulted, by OpenSSH and by the
HostKeys.lookup paramiko authenticates with, so the shadowed key is trusted
silently while the server's own key is then reported as a mismatch. It is
reported at the error level, unlike an absent key, and the two checks share
their server filter and their known_hosts load rather than repeating them.

check_connections() did `self._sftp, self._ssh = self.connect()`, but connect()
assigns both itself and returns None, so that branch raised `TypeError: cannot
unpack non-iterable NoneType object` for exactly the case it exists to serve, a
client that has not connected yet. Nothing hit it while every caller used
`with SSHClient(...)`, which connects in __enter__ -- except trsh_job_on_server,
whose bare `ssh = SSHClient(server)` meant that troubleshooting a remote job by
changing node could not work at all. Call connect() for its effect.

ARC's ESS survey opened one connection per server and dropped it, immediately
before the scheduler needed a connection to the same servers; it borrows from
the pool instead, so that connection is the one the run's jobs then use.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: SSHClient gains a configurable
connection_attempts, delete_remote_check_files() and the module-level
delete_check_files_on_servers(), and ARC.clean_check_files() calls both halves of
the cleanup at the end of a run, so the checkfiles a run leaves on a server are
deleted rather than only the local ones.

Four places where that work and this branch's own changes to ssh.py met were
resolved rather than taken from one side:

* shlex is imported once; this branch already imported it to quote remote
  commands.
* __init__ keeps this branch's `servers[server].get('key') or None`, not #1000's
  `servers[server]['key']`. An optional key is this branch's point, and #1000's
  line was written against the older mandatory one.
* connect() keeps this branch's permanent-failure classification and takes
  #1000's configurable attempt count. They are independent: the classification
  decides whether a failure is worth retrying at all, connection_attempts decides
  how long retrying goes on. A permanent failure still raises on the first
  attempt whatever connection_attempts is set to.
* The new methods are placed by subject rather than at #1000's line offsets:
  delete_remote_check_files() next to remove_dir(), the other remote deletion
  primitive, and delete_check_files_on_servers() with the other module-level
  server helpers.

delete_remote_check_files() builds a shell command, so its remote path is quoted
with shlex.quote() like every other interpolation in this module.
calvinp0 added a commit that referenced this pull request Aug 20, 2026
The pool was built and tested but the highest-frequency caller never used it.
Scheduler.get_server_job_ids() opens a connection per server per poll cycle,
for every cycle of every job's lifetime -- on a run of any length that is the
dominant source of connections by an order of magnitude, and it is exactly the
traffic shape a per-user connection limit is there to stop. It borrows now, so
a run's polling costs one connection per server rather than one per poll.

The same for the three sites in trsh_job_on_server() and for CFour's
execute_queue(). CFour overrides execute_queue() rather than calling
JobAdapter.legacy_queue_execution(), so it did not inherit the sharing the
other adapters got; it goes through _open_or_borrow_ssh(), which means its
submission also reuses the client its upload just used.

One of those trsh sites leaked. `ssh = SSHClient(server)` with no `with` and no
close() left a connection open for the rest of the process every time a job was
troubleshooted by changing node. It never actually reached the server, since
check_connections() raised TypeError on an unconnected client (fixed with the
rest of the SSH work), but the leak is real for any caller that got past it.

Not routed: delete_all_arc_jobs() in arc/job/ssh.py. Its only caller is
arc/utils/delete.py, a standalone command-line utility that deletes jobs and
exits, outside any ARC run; it opens no connection ARC would otherwise reuse
and its `with` already closes what it opens, so pooling would swap a closed
connection for one left open until the interpreter exits. ssh.py is also the
module ssh_pool.py imports, so pooling there would have to be a
function-local import to avoid a cycle -- a cost with nothing bought.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: the scheduler records the
remote project path of each server it spawns a job on, which is what the end of
a run hands to the check file cleanup.
calvinp0 added a commit that referenced this pull request Aug 20, 2026
PipeRun.submit_to_scheduler() invokes qsub/sbatch on the machine running ARC,
and the worker (python -m arc.scripts.pipe_worker) reads pipe_root from its
local filesystem. When the engine's resolved server is remote, that submission
errors silently and the run deadlocks waiting for results that can never
arrive.

Make should_use_pipe() refuse a non-local server so the planner falls back to
per-job queue submission over SSH, and say in the log which engine and server
triggered the refusal and what is being used instead -- that fallback is slower
than a pipe run, so without the message the only symptom is an unexplained
slowdown.

Supporting pipe on a remote server needs it rebuilt around batch jobs staged on
the remote side, which is out of scope here.

The guard resolved its server with `next((s for s in server_list if s in
servers_dict), None)`, which fails open in three ways: it skips an entry that
names an unconfigured server and silently judges the next one instead, it
compares server names case-sensitively when a server name is a settings key
whose casing the user chose, and it permits the pipe when nothing resolves at
all. That last one matters most, because derive_cluster_software() applies the
same "skip what is not configured" rule and then falls back to guessing slurm,
so an unresolvable server produced a pipe submitted with a guessed template.

Resolve the first entry unconditionally, compare case-insensitively, and refuse
unless the result is a configured server that is this machine. Refusing costs
the run only the bundling -- the planner submits the tasks as individual queue
jobs, which works for a local and a remote server alike -- so failing closed
here is cheap and failing open is not.

"Cannot be resolved" is not the same as "has no server", and conflating the two
would have disabled TSG pipe mode outright. A TS-guess batch carries
engine=<method>, and gcn, kinbot, xtb_gsm and the rest are not ESS: they are
absent from ess_settings by design and run in this process, which is why
_initialize_adapter resolves a server only for an engine ess_settings names and
leaves every other one with server=None, and why set_file_paths gives such a
job no remote path at all. In process is this machine, so those tasks pipe. The
refusal is for an engine that ess_settings does name and that still does not
resolve to a configured local server -- an ESS declared and available nowhere,
or named on a server that is not configured.

The resolution itself is not a second implementation. _initialize_adapter()
already decided which server a job goes to, inline: a trsh override first, then
the first entry of the ESS settings for the adapter, with a bare string read as
a single server. That is now resolve_job_server() in arc/job/adapters/common.py,
the module that owns the concept, called by both, so the pipe's answer is the
answer the job would have got rather than a lookalike. Extracting it also fixes
an IndexError on an empty server list, and drops a redundant re-check of a
condition the enclosing `if` had already established.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: _initialize_adapter()
initializes the new remote_project_path attribute.
calvinp0 added a commit that referenced this pull request Aug 20, 2026
The container and SSH halves of remote submission were in place; what was missing
was the user-facing configuration around them. These docs are written against the
`key` and host-key semantics this branch introduces, not against main's.

docs/source/remote_submission.rst (new, in the toctree): authentication via a
forwarded agent (preferred -- keys never enter the container and
passphrase-protected keys keep working) or via a mounted key file; host key
verification and the new per-server strict_host_key_checking, including why an
unseeded known_hosts matters in a fresh container now that WarningPolicy and
RejectPolicy have replaced the silent AutoAdd; the ~/.arc overlay mount, which a
remote run needs as much as the SSH material since submit.py carries the cluster's
PBS/Slurm templates; both `docker run` invocations and the compose equivalent; and
the entrypoint's exit codes.

Two limitations are documented rather than worked around:
- ARC never builds a paramiko.SSHConfig, so ~/.ssh/config is not read at all and
  ProxyJump/bastion hosts are unsupported. This is true on bare metal too, and is
  called out so nobody blames the container for it.
- a default-bridge container reaches an ordinary login node with no extra flags,
  since paramiko speaks SSH itself; the exceptions are a host VPN whose routing
  excludes docker0, and internal names served only by a VPN-pushed resolver.

Each claim is checked against the code rather than assumed: paramiko's
load_system_host_keys() reads ~/.ssh/known_hosts and nothing else, so
/etc/ssh/ssh_known_hosts is not mentioned as an alternative (seeding it under
strict_host_key_checking would have refused every connection); a rejected host key
raises into the same 24-hour retry loop and so presents as a hang rather than a
fast failure; the retry reason reaches the logger only on every tenth attempt,
the others going to stdout; and Docker materialises a missing bind-mount source as
a root-owned directory, which is what a stale SSH_AUTH_SOCK or an absent ~/.arc
produces on the host.

installation.rst and running.rst described `key` as a private key path, which was
wrong on main and is right as of this branch; they now say so, present the agent
route as the default, and mention strict_host_key_checking. docker.rst gains the
remote-submission pointer, index.rst the toctree entry, and the stray `key` in the
advanced.rst node-limits example is dropped, since that example is about cpus and
memory.

remote_submission.rst now also states why warning rather than rejecting is the
default host-key policy -- a refused key does not fail a long-running scheduler
once but starves it while the driver stays alive, and it presents as a hang
rather than an error -- and how to opt into refusal per server. The Startup
Checks section leads with the check ARC itself performs on every run, in a
container or not, and describes the compose file's known_hosts mount and why
ARC_KNOWN_HOSTS defaults to /dev/null. running.rst gains a pointer to the same
startup report.

Absorbed from PR #1000 by @alongd, brought in here rather than merged so the two
pull requests do not conflict over the same lines: keep_checks is documented as
covering the servers a project ran on, not only the local project directory.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants