Add SSH SFTP backend support

This commit is contained in:
2026-05-31 16:53:37 +00:00
parent 1ad566264f
commit 84f77ec0d0
29 changed files with 1629 additions and 40 deletions

View File

@@ -19,11 +19,11 @@ distributor inspect <path>
```
- `version`: prints the application name and version. Development builds print `distributor dev`.
- `run`: loads a YAML config, discovers local source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
- `run`: loads a YAML config, discovers source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
- `validate`: validates a local source bundle directory or a local tree containing source bundles.
- `inspect`: validates local source bundles and prints normalized bundle metadata.
`validate` and `inspect` accept local paths only. `run` currently executes local backends only. SSH and S3 config can be parsed and validated, but configured SSH or S3 execution fails with a clear unsupported-execution error.
`validate` and `inspect` accept local paths only. `run` executes `local` and `ssh` backends. S3 config can be parsed and validated, but configured S3 execution fails with a clear unsupported-execution error.
## Flag reference

View File

@@ -10,7 +10,7 @@ If `--config` is omitted, `run` uses:
/usr/local/etc/distributor/config.yml
```
Config parsing rejects unknown YAML fields. The current executable backend support is local only. SSH and S3 config fields are accepted by config validation, but runtime execution for those backends is unavailable.
Config parsing rejects unknown YAML fields. The executable backends are `local` and `ssh`. S3 config fields are accepted by config validation, but runtime execution for S3 is unavailable.
## Minimal Local Config
@@ -85,7 +85,12 @@ Source backend:
- `backend`: required.
- `path`: required for `local` and `ssh`.
- `uri`: required for `ssh`.
- `host`: required for `ssh`.
- `user`: optional for `ssh`; defaults to the current OS user when available.
- `port`: optional for `ssh`; defaults to `22`.
- `ssh_key_file`: optional for `ssh`.
- `known_hosts`: optional for `ssh`; defaults to the service user's OpenSSH `known_hosts` path when available.
- `host_key_policy`: optional for `ssh`; defaults to `accept-new`.
- `endpoint`: required for `s3`.
- `bucket`: required for `s3`.
- `prefix`: optional for `s3`.
@@ -105,9 +110,34 @@ Destination:
Accepted backend names:
- `local`: executable; requires `path`.
- `ssh`: config validation only; execution is unavailable.
- `ssh`: executable; requires `host` and `path`.
- `s3`: config validation only; execution is unavailable.
## SSH Backend
SSH uses native SFTP. It can be used for sources, destinations, or both:
```yaml
backend: ssh
host: example.com
user: distributor
port: 2222
path: /remote/root
ssh_key_file: /home/distributor/.ssh/id_ed25519
known_hosts: /home/distributor/.ssh/known_hosts
host_key_policy: accept-new
```
Authentication uses SSH agent identities first when `SSH_AUTH_SOCK` is set, then `ssh_key_file` if configured. Password authentication in YAML is not supported.
Host key policies:
- `strict`, `true`, and `"true"` require a matching known host key.
- `accept-new` accepts and persists a new host key, but fails if an existing key changed.
- `off`, `false`, and `"false"` disable host key checking and are insecure.
`accept-new` and `strict` use `known_hosts` when configured. If omitted, distributor uses the current service user's default OpenSSH `known_hosts` path where practical. `accept-new` fails when it needs to persist a new host key and no writable `known_hosts` path is available. It does not create a missing parent `.ssh` directory.
Publish policy:
- `publish.source`: publish source artifacts.
@@ -127,6 +157,8 @@ Transfer policy:
Defaults are applied after YAML decoding and before validation:
- `validation.on_digest_mismatch: fail`
- SSH `port: 22`
- SSH `host_key_policy: accept-new`
- `publish.source: true`
- `publish.html: false`
- `transfer.on_destination_same: skip`
@@ -151,3 +183,4 @@ Maintained examples live under [examples](../examples/):
- `local-publish.yml`: runnable local source publication.
- `local-html.yml`: runnable local HTML publication.
- `fan-out.yml`: runnable local fan-out publication to source and HTML destinations.
- `ssh-destination.yml`: environment-gated local-to-SSH publication example.

View File

@@ -27,7 +27,7 @@ Destination failures are collected while later destinations continue to run. Sou
## Backend and transform wiring
The app-level backend factory registers only the local backend for execution. Config validation accepts other backend shapes, but `Run` can execute only local sources and local destinations.
The app-level backend factory registers local and SSH backends for execution. Config validation accepts S3 shape, but `Run` cannot execute S3 sources or destinations.
The app-level transform registry registers Markdown-to-HTML using `internal/transform/markdown`. Lower-level publish code receives a resolver and does not import concrete transform implementations.
@@ -45,7 +45,7 @@ Stdout write errors are returned immediately because the caller's requested outp
`internal/app` coordinates packages but does not own manifest validation rules, destination state comparison, storage path rules, output planning, transform rendering, or backend-specific filesystem behavior.
`Validate` and `Inspect` are local path commands. Remote execution wiring is outside current behavior.
`Validate` and `Inspect` are local path commands. Remote execution wiring currently belongs to `Run`.
## Tests

View File

@@ -19,6 +19,8 @@ Known-field checking rejects misspelled or unknown YAML keys before defaults and
Defaults are applied in `ApplyDefaults`:
- pipeline validation defaults `on_digest_mismatch` to `fail`;
- SSH backend `port` defaults to `22`;
- SSH backend `host_key_policy` defaults to `accept-new`;
- destination publish policy defaults to source output only;
- `transfer.on_destination_same` defaults to `skip`;
- `transfer.on_destination_older` defaults to `replace`;
@@ -33,7 +35,9 @@ Validation requires at least one pipeline, slug-like unique pipeline ids, one so
## Executable support boundary
Config validation accepts `local`, `ssh`, and `s3` backend shapes so config files can be validated as schemas. Runtime execution currently opens only local backends through `internal/app`.
Config validation accepts `local`, `ssh`, and `s3` backend shapes so config files can be validated as schemas. Runtime execution opens local and SSH backends through `internal/app`; S3 remains accepted by validation but unavailable at execution.
SSH config uses structured fields: `host`, optional `user`, optional `port`, `path`, optional `ssh_key_file`, optional `known_hosts`, and optional `host_key_policy`. `host_key_policy` accepts YAML booleans and strings and normalizes `true`/`strict`, `accept-new`, and `false`/`off`.
The user-facing configuration reference is `docs/config.md`; this file documents package behavior for maintainers.

View File

@@ -14,7 +14,7 @@ Entries report a logical path, type, and size when available. Entry types are `f
Core packages should depend on `internal/storage`, not adapter packages. Adapter-specific path handling stays behind backend implementations.
The local adapter lives in `internal/adapters/local`. Runtime backend construction is wired through the app-level backend factory and storage registry. The fake backend lives in `internal/storage/fake` for tests and is not registered for runtime use.
The local adapter lives in `internal/adapters/local`. The SSH/SFTP adapter lives in `internal/adapters/ssh`. Runtime backend construction is wired through the app-level backend factory and storage registry. The fake backend lives in `internal/storage/fake` for tests and is not registered for runtime use.
## Paths
@@ -30,10 +30,12 @@ Backends may wrap implementation-specific errors, but callers should receive sto
Backends expose guarded managed deletion only. `DeleteManagedBundle` may delete listed managed outputs plus `.distributor.json`; it does not provide broad recursive deletion.
## Local and fake backends
## Local, SSH, and fake backends
The local adapter maps logical paths to a configured filesystem root and keeps adapter-specific path handling behind the storage interface.
The SSH adapter maps logical paths to a configured remote SFTP root. It uses native SSH and SFTP libraries, supports SSH agent and key-file authentication, applies host-key policies, rejects unsafe logical paths, reports symlink entries from `Lstat`, and limits deletion to managed targets.
The fake backend is an in-memory implementation for package tests. It is not registered for runtime use.
## Tests
@@ -43,6 +45,7 @@ Before changing storage behavior, inspect tests under:
- `internal/storage`
- `internal/storage/fake`
- `internal/adapters/local`
- `internal/adapters/ssh`
## Invariants

View File

@@ -32,13 +32,21 @@ Preview local fan-out publication:
go run ./cmd/distributor run --config examples/fan-out.yml --dry-run
```
Preview an environment-gated SSH destination config after editing it for an SSH/SFTP endpoint you control:
```sh
go run ./cmd/distributor run --config examples/ssh-destination.yml --dry-run
```
## Filesystem Layout
Source bundles are discovered beneath the configured local source root. Each bundle is a directory containing `manifest.json`.
Source bundles are discovered beneath the configured source root. Each bundle is a directory containing `manifest.json`.
Destination bundle paths preserve the source bundle path relative to the source root. A source bundle at the source root publishes to the destination root. A source bundle under `daily/` publishes under `daily/` at each destination.
The maintained examples write under `workspace/`, which is ignored by Git.
The maintained local examples write under `workspace/`, which is ignored by Git.
SSH backends use the configured remote `path` as the backend root. Source bundle discovery and destination bundle paths are relative to that root, using the same logical path rules as local storage.
## Destination State
@@ -74,12 +82,22 @@ If a destination path has files but no valid `.distributor.json`, publication fa
If one destination fails in a fan-out run, independent later destinations are still planned and executed. The command exits non-zero after printing the final status if any destination failed.
If a write fails during local publication, `distributor` attempts to remove outputs written during that failed attempt so a retry does not see those partial outputs as unmanaged destination content.
If a write fails during publication, `distributor` attempts to remove outputs written during that failed attempt so a retry does not see those partial outputs as unmanaged destination content.
After a successful publish or replacement, the internal notifier hook runs. The current default notifier is a no-op. Skipped destinations do not invoke it.
## SSH Operation Notes
SSH execution uses SFTP over `golang.org/x/crypto/ssh` and `github.com/pkg/sftp`. It does not shell out to `ssh`, `scp`, or `rsync`.
Configure `ssh_key_file`, an SSH agent, or both. Agent identities are attempted first, followed by the configured key file. YAML password authentication is not supported.
The default host key policy is `accept-new`. New host keys are written to `known_hosts` when the file path is writable. Changed host keys are fatal for both `strict` and `accept-new`. The `off` policy disables host key checking and `run` prints a warning when stdout is enabled.
Recovery boundaries are the same as local storage: replacement deletes only managed output paths recorded in `.distributor.json` plus the state file, and failed writes are cleaned up where practical. Distributor never performs broad recursive remote deletion.
## Caveats
Only local-to-local execution is available. SSH execution, S3 execution, external notification adapters, and force overwrite behavior are unavailable.
S3 execution, external notification adapters, and force overwrite behavior are unavailable.
For symptom-oriented fixes, see [troubleshooting](troubleshooting.md). For config details, see [configuration](config.md). For command syntax, see [CLI](cli.md).

View File

@@ -102,7 +102,7 @@ When adding or changing configuration:
Config validation may accept fields for backends that are not executable yet,
but user-facing docs and examples must clearly state execution support. At the
time of this policy, only the local backend is executable.
time of this policy, local and SSH backends are executable.
## CLI Changes
@@ -117,7 +117,7 @@ When adding or changing commands or flags:
4. Update `docs/cli.md` if syntax, flags, output expectations, or workflows change.
`validate` and `inspect` are local path commands. `run` loads configured
pipelines and currently executes local backends only.
pipelines and currently executes local and SSH backends.
## Storage Backends
@@ -133,8 +133,8 @@ When adding a backend:
5. Add focused adapter tests and app-level wiring tests.
6. Update user docs, operations docs, examples, and internal docs only for behavior that is actually implemented.
Do not document SSH/SFTP or S3 execution as available until corresponding
adapter packages and app wiring exist.
Do not document S3 execution as available until the corresponding adapter
package and app wiring exist.
## Transforms

View File

@@ -34,11 +34,11 @@ Diagnostic:
rg -n "backend:" <config-path>
```
Safe fix: use `backend: local` for executable workflows. SSH and S3 config shapes are accepted only for validation; runtime execution is unavailable.
Safe fix: use `backend: local` or `backend: ssh` for executable workflows. S3 config shape is accepted only for validation; runtime execution is unavailable.
## `backend ssh is not implemented for execution` or `backend s3 is not implemented for execution`
## `backend s3 is not implemented for execution`
Likely cause: the config validates but `run` tried to execute a remote backend.
Likely cause: the config validates but `run` tried to execute S3, which is not implemented.
Diagnostic:
@@ -46,7 +46,69 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --dry-run
```
Safe fix: use local destinations for current executable workflows, or keep remote backend configs under roadmap material unless those adapters are added. See [configuration](config.md).
Safe fix: use `local` or `ssh` for executable workflows. See [configuration](config.md).
## `host is required for ssh backend`
Likely cause: SSH config is missing the structured `host` field, or an old URI-based SSH config is still in use.
Diagnostic:
```sh
go run ./cmd/distributor run --config <config-path> --dry-run
```
Safe fix: configure SSH with `host`, optional `user` and `port`, and `path`. The `uri` field is not used for SSH execution.
## `no SSH auth methods configured`
Likely cause: neither an SSH agent nor `ssh_key_file` is available.
Diagnostic:
```sh
test -n "$SSH_AUTH_SOCK" && ssh-add -l
ls -l <ssh-key-file>
```
Safe fix: start an SSH agent with an appropriate key loaded, or configure `ssh_key_file` with a readable private key.
## `host key ... is unknown` or `known_hosts is required`
Likely cause: strict host key checking has no known host key, or `accept-new` cannot persist a new key.
Diagnostic:
```sh
ls -l <known-hosts-path>
ssh-keygen -F <host> -f <known-hosts-path>
```
Safe fix: configure a writable `known_hosts` path for `accept-new`, pre-populate `known_hosts` for `strict`, or explicitly use `host_key_policy: off` only for insecure test environments.
## `host key ... has changed`
Likely cause: the remote server presented a different host key than the one recorded in `known_hosts`.
Diagnostic:
```sh
ssh-keygen -F <host> -f <known-hosts-path>
```
Safe fix: verify the server identity out of band before updating `known_hosts`. Do not switch to `host_key_policy: off` to bypass an unexpected changed key.
## `stat ssh ... not_found` or `no bundles found`
Likely cause: the configured SSH `path` is wrong, unreadable, or does not contain source bundles.
Diagnostic:
```sh
sftp <user>@<host>
```
Safe fix: correct the remote root `path`, permissions, or source bundle location.
## `validate command requires a path` or `inspect command requires a path`