Writing a remote adapter¶
For operators and integrators who need to reach a machine the packaged
local, local-slurm, and ssh-slurm templates do not cover. This page is the
normative reference for the adapter contract: the bundle layout, the seven
operations and their exact JSON request and result documents, how settings and
credentials reach an adapter, and the rules an implementation must follow. The
operator-facing description of the same adapters — what each maintained kind
does, and which command-line options drive it — is in
Project and workflow command line.
A remote adapter is a versioned directory with one dispatcher executable.
Everything httk-workflow does on another machine — push a job bundle, run a
command, submit a manager, pull results back — is one operation, and every
operation runs the bundle’s single adapter program. The engine never opens an
ssh connection, never spells out a scheduler command, and never parses anything
but the one JSON document that program prints.
One executable, seven operations¶
There is one executable per bundle, not one per operation. The operation to run
is named inside the request JSON ("operation": …), so a single program serves
all seven. Earlier releases carried seven wrapper files —
configure, install, invoke, push, pull, start-manager, status —
but they were byte-identical two-line thin execs that differed only in the
operation word they passed along. There was no security boundary between them:
the operation name is already stated, and cross-checked, inside the request,
so which file was executed proved nothing the request did not already carry.
Collapsing them to one adapter removes a maintenance burden and a false
signal without giving up anything. The operation names are unchanged — they
are httk.workflow.adapters.ADAPTER_OPERATIONS and the value of the
request’s operation member — only the file layout changed.
The bundle¶
my-cluster/
├── remote.json # the only member the engine reads directly
├── adapter # the one dispatcher, executable, run for every operation
└── credentials.json # written by the CLI, never by you, mode 0600
Bundles live in one of two places, and a project-local definition shadows a global one of the same name:
Scope |
Location |
|---|---|
project |
|
global |
|
The metadata file is remote.json, below a remotes/ directory; those are the
only spellings a bundle is read under.
Historical protocol names¶
The file was renamed; the format identifiers inside it and in every request and
result document were not. httk-computer-adapter, httk-computer-request, and
httk-computer-result are protocol: an adapter written against an earlier
release, or a bundle authored elsewhere, must keep validating and keep being
understood, and renaming an identifier would refuse it for no reason at all.
Read them as historical spellings of remote; every side already agrees on
them, and nothing new should be added under the older word.
remote.json¶
The document is validated by
httk.workflow.adapters.validate_adapter_bundle() every single time the
bundle is resolved, added, or run — not once at installation. A bundle that stops
satisfying it stops being usable, which is the point.
{
"adapter_version": 1,
"format": "httk-computer-adapter",
"format_version": 1,
"kind": "pbs",
"queues": {
"default": {"host": "login.example.org", "workspace": "/scratch/me/runs"},
"large": {"host": "login.example.org", "workspace": "/scratch/me/runs", "nodes": "16"}
},
"required_binaries": ["rsync", "ssh"],
"timeout_seconds": 300
}
There is no operations member. The bundle carries one executable named
adapter, which validation requires to exist and be executable; the operation
is selected by the request, not by a per-operation path.
Member |
Required |
Meaning |
|---|---|---|
|
yes |
must be |
|
yes |
must be |
|
yes |
must be |
|
no |
maps queue name to a settings object; defaults to |
|
no |
positive number, default |
|
no |
array of program names that must be on |
|
no |
free-form; see below |
Beside remote.json the bundle must contain one executable file named
httk.workflow.adapters.ADAPTER_EXECUTABLE (adapter); validation
refuses a bundle whose adapter is missing or not runnable.
kind is not interpreted by the loader. It is read only by
httk.workflow.adapter_protocol — the packaged implementation the
maintained templates execute — which dispatches on it and refuses any value
outside local, local-slurm, ssh-slurm rather than running the wrong code in
the wrong place. A custom adapter whose adapter executes your own program may
put whatever it likes there; setting a distinctive value is still worth doing,
because an adapter accidentally repointed at the packaged implementation then
refuses instead of, say, copying a cluster job into the local filesystem.
required_binaries is what makes local-slurm unusable on a machine without
sbatch, and it is checked locally, at validation time. Do not list binaries
that only exist on the far side of a connection: the local ssh client is a
local requirement, the remote qsub is not.
The seven operations¶
Every operation runs the same adapter executable. It is started as
adapter /tmp/httk-adapter-XXXX.json
with no shell, no environment contract, and no stdin — one argument, the request file. The program must:
read the one JSON request file named by
argv[1];read
request["operation"]to learn which operation to perform;do the work;
print exactly one JSON result object on stdout;
exit
0.
Diagnostics belong on stderr, where they are attached to the result as
diagnostics when the call otherwise succeeds.
The maintained template is a one-line dispatcher that executes the packaged module:
#!/bin/sh
exec python3 -m httk.workflow.adapter_runtime "$@"
Which operation is running is fixed by the request’s operation member and
nothing else; the module dispatches on it. A result whose operation disagrees
with the request is rejected by httk.workflow.adapters.run_adapter().
The request envelope¶
httk.workflow.adapters.run_adapter() composes every request. The
envelope is always present:
{
"format": "httk-computer-request",
"format_version": 1,
"operation": "invoke",
"adapter_dir": "/home/me/project/.httk-project/remotes/my-cluster",
"queue": "default",
"queue_settings": {"host": "login.example.org", "workspace": "/scratch/me/runs"}
}
adapter_diris the absolute, resolved bundle directory. It is how an adapter finds its own files; nothing else tells it where it lives.queue_settingsis present exactly when the caller named aqueue. It is the merge described under Settings and credentials.Everything else is operation-specific and documented per operation below.
The request file is written with sort_keys=True and removed as soon as the
operation returns, whether it succeeded, failed, or timed out.
The result envelope¶
{"format": "httk-computer-result", "format_version": 1, "operation": "invoke", "ok": true}
run_adapter rejects a result that is not one JSON object, or whose format,
format_version, or operation disagree with the call it made. A refusal is
the same envelope with ok: false and a human-readable error:
{"error": "cannot reach me@login.example.org: Permission denied", "format": "httk-computer-result",
"format_version": 1, "operation": "configure", "ok": false}
configure¶
Verify that a queue’s settings can work, before the command line persists them.
Request members beyond the envelope:
Member |
Type |
Meaning |
|---|---|---|
|
object |
the pending |
Pending settings are passed separately because storage happens only after this
operation succeeds; an adapter that only looked at queue_settings could never
validate the first configuration of a host. Merge settings over
queue_settings and check the result.
{"format": "httk-computer-request", "format_version": 1, "operation": "configure",
"adapter_dir": "/home/me/.config/httk/remotes/my-cluster", "queue": "default",
"queue_settings": {"workspace": "/scratch/me/runs"},
"settings": {"host": "login.example.org", "username": "me"}}
{"connectivity": "ok", "configured": true, "format": "httk-computer-result",
"format_version": 1, "operation": "configure", "ok": true}
The maintained implementation reports connectivity as ok when a remote true
answered, and skipped when there is no host or the queue sets
check_connectivity=no.
install¶
Report — and only where explicitly opted into, arrange — that the target can run httk-workflow, and ensure the queue’s workspace directory exists.
No request members beyond the envelope.
{"format": "httk-computer-request", "format_version": 1, "operation": "install",
"adapter_dir": "/home/me/.config/httk/remotes/my-cluster", "queue": "default",
"queue_settings": {"host": "login.example.org", "username": "me",
"workspace": "/scratch/me/runs", "bootstrap": "pip"}}
{"bootstrapped": false, "format": "httk-computer-result", "format_version": 1,
"httk_command": ["httk"], "httk_version": "httk 2.0.0", "installed": true,
"operation": "install", "ok": true, "workspace": "/scratch/me/runs",
"workspace_created": true}
Result member |
Meaning |
|---|---|
|
|
|
the argument vector that answered, as an array |
|
its |
|
whether this call installed anything (only ever under |
|
present when the queue configures a |
Answering “httk-core is installed” is not enough: the maintained implementation
also runs httk workflow workspace --help, because the workflow command group
exists exactly when this package is installed beside the core. Installing
software on someone else’s cluster account is never the default: without
bootstrap=pip, a target with no httk is a refusal carrying the instruction to
install it.
invoke¶
Run one argument vector where this adapter’s work belongs, and report what it did.
Member |
Type |
Meaning |
|---|---|---|
|
array of nonempty strings, required |
the command |
|
string, optional |
the directory to run it in |
{"argv": ["httk", "workflow", "workspace", "status", "/scratch/me/runs", "--json"],
"cwd": "/scratch/me", "format": "httk-computer-request", "format_version": 1,
"operation": "invoke", "adapter_dir": "/home/me/.config/httk/remotes/my-cluster",
"queue": "default", "queue_settings": {"host": "login.example.org"}}
{"format": "httk-computer-result", "format_version": 1, "operation": "invoke", "ok": true,
"returncode": 0, "stdout": "{\"format\": \"httk-workflow-status\", ...}\n", "stderr": ""}
A nonzero returncode is still ok: true. The operation succeeded — it ran
the command and is reporting the outcome. ok: false means the adapter could not
run it at all. Callers check returncode themselves; every remote command in
httk workflow transfer … does exactly that and raises on the value.
If argv[0] is the literal httk, an adapter is expected to honour the queue’s
httk_command setting by replacing that one element with the parsed vector; see
Spelling httk on the target.
status¶
Byte-for-byte the same contract as invoke, except that cwd is ignored. It
exists as a separate operation so that a health probe can be given a different
implementation, a different timeout, or different credentials from arbitrary
command execution. httk workflow transfer fetch uses it to check that the far
side is a compatible workspace before anything moves.
{"argv": ["httk", "workflow", "workspace", "status", "/scratch/me/runs", "--json"],
"format": "httk-computer-request", "format_version": 1, "operation": "status",
"adapter_dir": "/home/me/.config/httk/remotes/my-cluster", "queue": "default",
"queue_settings": {"host": "login.example.org"}}
{"format": "httk-computer-result", "format_version": 1, "operation": "status", "ok": true,
"returncode": 0, "stdout": "{\"format\": \"httk-workflow-status\", ...}\n", "stderr": ""}
push and pull¶
Move one tree, or one explicit batch of files, to (push) or from (pull) the
target.
Member |
Type |
Meaning |
|---|---|---|
|
nonempty string, required |
where the data is now |
|
nonempty string, required |
where it must end up |
|
boolean, optional |
whether the transfer is of a directory’s contents; inferred from the local side when absent |
|
array of relative paths, optional |
transfer only these, relative to |
files entries are refused if absolute or if they contain ..: a transfer
manifest must not be able to name anything outside the workspace it came from.
{"destination": "/scratch/me/runs/.httk-workflow/transfers/incoming/6f1c…",
"format": "httk-computer-request", "format_version": 1, "operation": "push",
"source": "/home/me/ws/.httk-workflow/transfers/outgoing/6f1c…",
"adapter_dir": "/home/me/.config/httk/remotes/my-cluster", "queue": "default",
"queue_settings": {"host": "login.example.org"}}
{"format": "httk-computer-result", "format_version": 1, "operation": "push", "ok": true,
"path": "/scratch/me/runs/.httk-workflow/transfers/incoming/6f1c…"}
path is where the data actually landed, and callers use it rather than the
destination they asked for. The maintained implementation reports the requested
destination for remote transfers and the resolved absolute path for local
copies, which is why the value is authoritative and the request is not.
A local copy onto an existing destination is idempotent when both sides carry the
identical .httk-transfer/manifest.json, and an error otherwise, so a resumed
transfer does not have to know whether the previous attempt finished.
start-manager¶
Start one or more task managers on the target.
Member |
Type |
Meaning |
|---|---|---|
|
array of nonempty strings, required |
the manager command, e.g. |
|
string, optional |
the workspace the managers serve |
|
positive integer, optional |
how many to start, default |
|
string, optional |
honoured by process-starting implementations |
The workspace member is stated outright by every caller in this package. When
it is absent the maintained implementation reads it back out of a
manager run WORKSPACE argument vector, and only then from the queue’s
workspace=PATH; that fallback is documented for hand-written requests and is
not the normal path.
{"argv": ["httk", "workflow", "manager", "run", "/scratch/me/runs"], "count": 2,
"format": "httk-computer-request", "format_version": 1, "operation": "start-manager",
"workspace": "/scratch/me/runs",
"adapter_dir": "/home/me/.config/httk/remotes/my-cluster", "queue": "large",
"queue_settings": {"account": "snic2026-1-1", "host": "login.example.org",
"nodes": "16", "partition": "main", "time_limit": "24:00:00",
"workers": "4"}}
Batch implementations report the submitted identifiers:
{"count": 2, "format": "httk-computer-result", "format_version": 1,
"job_ids": ["1840271", "1840272"], "operation": "start-manager", "ok": true,
"script": "/scratch/me/runs/.httk-workflow/batch/manager-9c1f….sbatch"}
Process implementations report the process identifiers instead, with pid
naming the first, so a caller that only ever started one manager reads the field
it always did:
{"count": 2, "format": "httk-computer-result", "format_version": 1,
"operation": "start-manager", "ok": true, "pid": 40311, "pids": [40311, 40312]}
An adapter should append the queue’s configured workers to argv only when the
request did not already choose one, so that an explicit --workers from the
command line always wins over the queue’s default.
Settings and credentials¶
httk workflow remote configure NAME --set KEY=VALUE splits every assignment
in two, by name:
keys in
httk.workflow.adapters.PERSISTABLE_QUEUE_SETTINGS—account,bootstrap,check_connectivity,cpus_per_task,host,httk_command,legacy_settings,nodes,partition,port,reservation,time_limit,username,workers,workspace— are written intoqueues.<QUEUE>of the shareable, signableremote.json;every other key is a credential. It is written per queue into
credentials.jsonbeside it, with mode0600, and project manifests exclude that file.
httk.workflow.adapters.queue_settings() merges the two back together —
remote.json first, credentials.json over it — and that single object is what
arrives as the request’s queue_settings. An adapter never sees the split.
It reads one flat settings object and cannot tell, and must not care, which file
a value came from. httk workflow remote show NAME reports which file each
setting came from, and the name only — never the value — of every credential.
Two consequences worth stating:
A credential is never a member of
remote.json, so a signed project manifest covering the bundle covers no secret.Adding a persistable key means adding it to
PERSISTABLE_QUEUE_SETTINGS. A key an adapter invents and the engine does not know about is treated as a secret, which is the safe direction to be wrong in.
Values arriving in queue_settings are strings as the operator typed them.
Validate them: the maintained implementation refuses a non-numeric port, a
host or username containing whitespace, a non-positive-integer workers, and
any batch directive value containing control characters.
No shell, ever¶
Every subprocess an adapter starts is an argument vector. No value that came from a request or from settings may be interpolated into a string that a shell will parse. This is not a style rule; it is the reason a workspace path with a space in it, or a hostile job tag, cannot become a command on a cluster login node.
ssh is the one unavoidable exception in the protocol, because it always joins
the command words it is given and lets a login shell on the far side parse the
result. The convention for that exception is a single helper, used everywhere,
that quotes element-wise:
def _shell_command(argv: Sequence[str], *, cwd: str | None = None) -> str:
quoted = " ".join(shlex.quote(item) for item in argv)
if cwd is None:
return quoted
return f"cd {shlex.quote(cwd)} && {quoted}"
Every remote command string, and the one line of a generated batch script that
runs the manager, is built by that helper and by nothing else. A generated batch
script is otherwise a shell program too, so the same rule applies to it: the
exec line is composed by the helper, and directive values are checked for
control characters rather than quoted into place.
rsync transfers pass --protect-args, so even file names travel inside the
protocol rather than through the remote shell. When an explicit files batch is
transferred, the list goes into a temporary file passed as --files-from= — not
onto the command line.
Exit codes, refusals, and timeouts¶
There are three distinct ways an operation can end, and they are not interchangeable.
Ending |
Exit |
stdout |
What the caller sees |
|---|---|---|---|
success |
|
one result with |
the result dictionary, plus |
refusal |
|
one result with |
|
crash |
nonzero |
ignored |
|
timeout |
— |
— |
|
A refusal is a well-formed answer: I understood the request and will not, or
cannot, carry it out. An unreachable host, a target without httk installed, an
unsupported kind — these are refusals, and the reason reaches the operator
verbatim. A crash is for what the adapter could not describe: a malformed
request, an unreadable bundle, an exception. The maintained implementation exits
2 with one stderr line for those and 0 for every refusal.
Prefer refusals. An operator reading cannot reach me@login.example.org: Permission denied; set check_connectivity=no to configure the queue anyway is
being told what to do next; an operator reading a traceback is not.
The timeout is timeout_seconds from remote.json, overridable per call by
--adapter-timeout on the command line. It is enforced by the caller, which
kills the operation; an adapter that may legitimately take minutes — an rsync
of a large campaign — belongs to a bundle whose timeout_seconds says so. The
packaged ssh-slurm template uses 300 for exactly this reason, against 60
for local.
A worked example: a PBS cluster¶
What follows is the skeleton of a custom bundle, not a working PBS
implementation. Everything specific to the site — how qsub is spelled, which
directives the queue wants, whether files travel by rsync or by a staging
service — lives in one Python module of yours, and the bundle is one adapter
dispatcher that calls it.
The dispatcher¶
The bundle’s adapter is one trivial script:
#!/bin/sh
# my-cluster/adapter
exec python3 -m mysite.httk_pbs "$@"
Make it executable (chmod +x); the bundle validator refuses one that is not.
Nothing else may live in it: keeping it trivial is what makes the “no shell”
rule cheap to hold, because no request value is ever visible to sh.
The metadata¶
{
"adapter_version": 1,
"format": "httk-computer-adapter",
"format_version": 1,
"kind": "pbs",
"queues": {
"default": {"host": "login.hpc.example.org", "workspace": "/scratch/me/runs"},
"wide": {"host": "login.hpc.example.org", "workspace": "/scratch/me/runs",
"nodes": "32", "time_limit": "12:00:00"}
},
"required_binaries": ["rsync", "ssh"],
"timeout_seconds": 300
}
The module¶
"""A site PBS adapter for httk-workflow. The bundle's `adapter` executes this module."""
import json
import shlex
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
OPERATIONS = ("configure", "install", "invoke", "push", "pull", "start-manager", "status")
def result(operation: str, **values: object) -> None:
print(json.dumps({"format": "httk-computer-result", "format_version": 1,
"operation": operation, "ok": True, **values}, sort_keys=True))
def refuse(operation: str, message: str) -> None:
print(json.dumps({"error": message, "format": "httk-computer-result",
"format_version": 1, "operation": operation, "ok": False}, sort_keys=True))
def shell_command(argv: Sequence[str], *, cwd: str | None = None) -> str:
"""The one place this module composes a string a shell will parse."""
quoted = " ".join(shlex.quote(item) for item in argv)
return quoted if cwd is None else f"cd {shlex.quote(cwd)} && {quoted}"
def ssh(settings: dict, argv: Sequence[str], *, cwd: str | None = None):
destination = settings["host"] if "username" not in settings \
else f"{settings['username']}@{settings['host']}"
return subprocess.run(
["ssh", "-o", "BatchMode=yes", "--", destination, shell_command(argv, cwd=cwd)],
text=True, capture_output=True, stdin=subprocess.DEVNULL, check=False,
)
def batch_script(argv: Sequence[str], settings: dict, workspace: str) -> str:
lines = ["#!/bin/bash", "#PBS -N httk-manager"]
for key, directive in (("account", "-A"), ("partition", "-q")):
if key in settings:
lines.append(f"#PBS {directive} {settings[key]}")
# ... nodes, walltime, and whatever else this site's queues want
lines += ["", "set -eu", f"cd {shlex.quote(workspace)}", f"exec {shell_command(argv)}", ""]
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
arguments = sys.argv[1:] if argv is None else argv
if len(arguments) != 1:
print("adapter dispatcher expects one REQUEST.json path", file=sys.stderr)
return 2
(request_path,) = arguments
try:
request = json.loads(Path(request_path).read_text(encoding="utf-8"))
operation = request.get("operation") if isinstance(request, dict) else None
if not isinstance(operation, str) or not operation:
raise ValueError("request carries no operation")
settings = request.get("queue_settings") or {}
if operation == "configure":
pending = {**settings, **(request.get("settings") or {})}
probe = ssh(pending, ["true"])
if probe.returncode != 0:
refuse(operation, f"cannot reach {pending.get('host')}: {probe.stderr.strip()}")
return 0
result(operation, configured=True, connectivity="ok")
elif operation == "install":
... # probe httk, mkdir -p the workspace, refuse with instructions
elif operation in {"invoke", "status"}:
cwd = request.get("cwd") if operation == "invoke" else None
completed = ssh(settings, request["argv"], cwd=cwd)
result(operation, returncode=completed.returncode,
stdout=completed.stdout, stderr=completed.stderr)
elif operation in {"push", "pull"}:
... # rsync --archive --protect-args, honouring `files` and `directory`
result(operation, path=request["destination"])
elif operation == "start-manager":
... # write batch_script(...) on the far side, qsub it `count` times
result(operation, job_ids=[...], count=request.get("count", 1))
else:
raise ValueError(f"unsupported adapter operation: {operation}")
except (OSError, RuntimeError, ValueError, KeyError, json.JSONDecodeError) as exc:
print(f"adapter {operation}: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
Installing and using it¶
The bundle is an ordinary directory; put it where the CLI looks and configure a queue:
mkdir -p .httk-project/remotes/my-cluster
cp -a my-cluster/. .httk-project/remotes/my-cluster/
httk workflow remote configure my-cluster --set username=me
httk workflow remote install my-cluster
httk workflow transfer start-manager my-cluster:wide --count 2
Nothing about the rest of the system changes: transfer send, transfer fetch,
and transfer status drive the new bundle through exactly the operations
above.
Reading the maintained implementation¶
The definitive worked example is the shipped one.
httk.workflow.adapter_protocol is its public name and carries the
contract in its docstring; httk.workflow.adapter_runtime is the
implementation the adapter dispatcher executes. Both names refer to the same
objects. Read
_shell_command, _rsync, and _batch_script there before writing any code
that composes a command for another machine.