Skip to content

ssh ​

transport remote hpc · maintainer machinable-org · upstream

Run an execution on a remote machine over SSH.

Install ​

Declare the remote in your project's provider; the module is fetched on first use:

python
# interface/project.py — your project's provider
from machinable import Project


class MyProject(Project):
    def on_resolve_remotes(self):
        return {
            "ssh": "url+https://raw.githubusercontent.com/machinable-org/machinable/6c6cb57c17808ac093ec2f954d3b790d5a5ae502/integrations/ssh/ssh.py",
        }

The module is fetched once and cached in interface/remotes/; machinable fetch ssh downloads it without importing so you can inspect the code first. See Resolving remotes for how pulling, pinning, and updating work.

ssh ​

Runs an execution on another machine while API server, the index, etc. stay local (see Transport).

bash
machinable get ssh slurm my_interface --launch

Configuration ​

python
from machinable import get

with get("ssh", {
    "host": "gpu.lan",                # anything ssh takes, including a config alias
    "directory": "/home/me/project",  # the project, on the far side
    "storage": "/scratch/me/storage", # the storage root, on the far side
}):
    get("my_interface").launch()

Monitoring ​

Nothing runs on the far side, so watching a run is a poll:

python
status = transport.wait(interface, timeout=3600)   # markers only, cheaply
transport.sync(interface)                          # the whole record, once

wait returns when the run finishes or when its heartbeat goes stale.

The poll fetches the *_at markers, the run's output.log, and the scheduler's job.out so following a job's log does not cost much. Artifacts come back on an explicit sync.

What it needs on the far side ​

Each command opens a connection and closes it again, with a short ControlPersist window so a burst shares one connection without holding it open. The far side needs the scheduler, and an interpreter for the job itself (e.g. Slurm's node_local + venv_tar)

Configuration ​

OptionTypeDefault
hoststrrequired
storagestr | NoneNone
directorystr | NoneNone
optionslist[str]['-o', 'BatchMode=yes', '-o', 'ControlMaster=auto', '-o', 'ControlPersist=60']
rsync_optionslist[str]['-a']

Source ​

integrations/ssh/ssh.py

ssh.py
py
"""SSH transport: run an execution on another machine."""

import os
import posixpath
import shlex

from pydantic import BaseModel, ConfigDict

from machinable import Project, Storage, Transport


class Ssh(Transport):
    """Reaches another machine over SSH."""

    class Config(BaseModel):
        model_config = ConfigDict(extra="forbid")

        host: str
        """``[user@]hostname`` as ssh would take it (including an ssh_config alias)."""

        storage: str | None = None
        """Storage root on the far side; mirrors the local one record for record."""

        directory: str | None = None
        """Project directory on the far side."""

        options: list[str] = [
            "-o",
            "BatchMode=yes",
            "-o",
            "ControlMaster=auto",
            "-o",
            "ControlPersist=60",
        ]
        """Passed to ssh."""

        rsync_options: list[str] = ["-a"]
        """Passed to rsync in both directions."""

    # -- addressing ---------------------------------------------------------

    def roots(self) -> list[tuple[str, str]]:
        """``(local, remote)`` directory pairs, longest local prefix first."""
        pairs = []
        if self.config.storage:
            pairs.append((os.path.abspath(Storage.get().root()), self.config.storage))
        if self.config.directory:
            pairs.append((os.path.abspath(Project.get().path()), self.config.directory))
        return sorted(pairs, key=lambda pair: -len(pair[0]))

    def path(self, local: str) -> str:
        """This machine's path as the far side sees it."""
        local = os.path.abspath(local)
        for base, remote in self.roots():
            if local == base:
                return remote
            if local.startswith(base + os.sep):
                relative = os.path.relpath(local, base).replace(os.sep, "/")
                return posixpath.join(remote, relative)
        raise ValueError(
            f"'{local}' is not under any directory {self} maps to the far side "
            f"({', '.join(base for base, _ in self.roots()) or 'none configured'}); "
            "set the transport's `storage` and `directory`."
        )

    def ssh_command(self) -> list[str]:
        """The ssh invocation prefix, without a remote command."""
        return ["ssh", *self.config.options, self.config.host]

    def run(self, cmd, **kwargs):
        """Run ``cmd`` on the far side."""
        remote = " ".join(shlex.quote(str(argument)) for argument in cmd)
        return super().run([*self.ssh_command(), remote], **kwargs)

    def push(self, local: str, remote: str | None = None, include=None) -> str:
        """Mirror a local directory to the far side."""
        remote = remote or self.path(local)
        self.run(["mkdir", "-p", remote], check=True)
        self._rsync(
            _as_directory(local),
            f"{self.config.host}:{_as_directory(remote)}",
            # a running job's markers are newer than the copies here; a
            # re-push (a resubmission, say) must not roll them back
            options=["--update", *_filter(include)],
        )
        return remote

    def pull(self, remote: str, local: str, include=None) -> bool:
        """Mirror a directory back from the far side."""
        os.makedirs(local, exist_ok=True)
        result = self._rsync(
            f"{self.config.host}:{_as_directory(remote)}",
            _as_directory(local),
            options=_filter(include),
            check=False,
        )
        return result.returncode == 0

    def _rsync(self, source: str, destination: str, *, options=None, check=True):
        transport = " ".join(shlex.quote(part) for part in self.ssh_command()[:-1])
        return super().run(
            [
                "rsync",
                *self.config.rsync_options,
                *(options or []),
                "-e",
                transport,
                source,
                destination,
            ],
            check=check,
        )


def _filter(include) -> list[str]:
    """rsync arguments selecting only ``include``, or everything when empty."""
    if not include:
        return []
    options = []
    for pattern in include:
        options += ["--include", pattern]
    # recurse everywhere, take only the named files
    return ["--include", "*/", *options, "--exclude", "*"]


def _as_directory(path: str) -> str:
    """Trailing slash: rsync copies a directory's contents, not the directory."""
    return path if path.endswith("/") else path + "/"

MIT Licensed