Compare commits
39 Commits
0818733b19
...
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 529172c754 | |||
| 7eed1a26ae | |||
| 1d71a151cc | |||
| 01e408f4d5 | |||
| 48169dc8b4 | |||
| 7a174ce5f1 | |||
| 14fa9c8000 | |||
| 052aa8a64a | |||
| 84f77ec0d0 | |||
| 1ad566264f | |||
| d530a46266 | |||
| c9e183b167 | |||
| 3512f1fca4 | |||
| 783d094007 | |||
| 14444c152b | |||
| 93c22884b0 | |||
| eac73a79a1 | |||
| b3044c5b7b | |||
| c36217d0df | |||
| edb9ac3a90 | |||
| 9d1ded301e | |||
| 9782981fb2 | |||
| 9c80e7179e | |||
| a973d0912d | |||
| f2ec7bd11e | |||
| be4a61fbe6 | |||
| 93805bcc8c | |||
| bda5f6ac6a | |||
| 61a40ab656 | |||
| 4bd5b19025 | |||
| 846dd1843d | |||
| 78f92154ab | |||
| 5408f14195 | |||
| e296361042 | |||
| aeee91940d | |||
| 518944e601 | |||
| 3c2f36a6e5 | |||
| 29dbad2967 | |||
| 22d0424232 |
50
.woodpecker/release.yml
Normal file
50
.woodpecker/release.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
- name: build-release-assets
|
||||
image: golang:1.26
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
version="$CI_COMMIT_TAG"
|
||||
dist="dist"
|
||||
pkg="gitea.maximumdirect.net/eric/distributor/cmd/distributor"
|
||||
|
||||
rm -rf "$dist"
|
||||
mkdir -p "$dist"
|
||||
|
||||
build_binary() {
|
||||
goos="$1"
|
||||
goarch="$2"
|
||||
suffix="$3"
|
||||
output="$dist/distributor-$version-$goos-$goarch$suffix"
|
||||
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/distributor/internal/buildinfo.Version=$version" \
|
||||
-o "$output" "$pkg"
|
||||
}
|
||||
|
||||
build_binary linux amd64 ""
|
||||
build_binary linux arm64 ""
|
||||
build_binary darwin amd64 ""
|
||||
build_binary darwin arm64 ""
|
||||
build_binary windows amd64 ".exe"
|
||||
build_binary windows arm64 ".exe"
|
||||
|
||||
- name: publish-release
|
||||
image: woodpeckerci/plugin-release
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITEA_RELEASE_TOKEN
|
||||
files:
|
||||
- dist/distributor-*
|
||||
checksum: sha256
|
||||
checksum-file: SHA256SUMS
|
||||
checksum-flatten: true
|
||||
file-exists: skip
|
||||
overwrite: false
|
||||
prerelease: false
|
||||
12
README.md
12
README.md
@@ -1,5 +1,13 @@
|
||||
# distributor
|
||||
|
||||
`distributor` is a planned Go application for validating and publishing manifested Markdown bundles.
|
||||
`distributor` validates manifested report bundles and publishes selected source or generated artifacts to configured destinations.
|
||||
|
||||
Implementation has not started yet. Current design and implementation planning lives under `docs/roadmap/`.
|
||||
It is a local-first CLI with SSH/SFTP and S3-compatible storage support: source bundles can be read from local or remote storage, destinations can be local directories or remote paths, and Markdown files can be rendered to HTML sidecars.
|
||||
|
||||
Run the local example pipeline:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
See [docs/cli.md](docs/cli.md), [docs/config.md](docs/config.md), [docs/operations.md](docs/operations.md), and [docs/troubleshooting.md](docs/troubleshooting.md) for the implemented CLI, configuration, operating notes, and common failure modes. Planning material lives under `docs/roadmap/`.
|
||||
|
||||
12
cmd/distributor/main.go
Normal file
12
cmd/distributor/main.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
109
docs/cli.md
Normal file
109
docs/cli.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Distributor CLI
|
||||
|
||||
## Shortest useful command
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
This discovers the example source bundle and publishes source files to `workspace/published/source-bundle`.
|
||||
|
||||
## Command overview
|
||||
|
||||
```sh
|
||||
distributor [--help]
|
||||
distributor version
|
||||
distributor run [--config <path>] [--dry-run] [--force]
|
||||
distributor validate <path>
|
||||
distributor inspect <path>
|
||||
```
|
||||
|
||||
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
||||
- `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` executes `local`, `ssh`, and `s3` backends.
|
||||
|
||||
## Flag reference
|
||||
|
||||
Root command:
|
||||
|
||||
- `--help`, `-h`, or `help`: print root help.
|
||||
|
||||
All subcommands:
|
||||
|
||||
- `--help`, `-h`: print command-specific help.
|
||||
|
||||
`run` flags:
|
||||
|
||||
- `--config <path>`: config file to load. If omitted, `run` uses `/usr/local/etc/distributor/config.yml`.
|
||||
- `--dry-run`: load config, discover bundles, inspect destination state, print planned actions and final status, and do not write output files, destination state, or SSH `known_hosts` entries.
|
||||
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
||||
|
||||
`run` does not accept positional arguments. `validate` and `inspect` accept at most one path; omitting the path returns a required-path error.
|
||||
|
||||
## Common workflows
|
||||
|
||||
Validate a source bundle:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate examples/source-bundle
|
||||
```
|
||||
|
||||
Inspect a source bundle:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor inspect examples/source-bundle
|
||||
```
|
||||
|
||||
Preview local publication without writing:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
|
||||
```
|
||||
|
||||
Publish the local source example:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
Publish the local HTML example:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-html.yml
|
||||
```
|
||||
|
||||
Preview local fan-out publication:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/fan-out.yml --dry-run
|
||||
```
|
||||
|
||||
Preview a forced replacement before publishing:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --force
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
`run` prints the number of configured pipelines, one line per pipeline, one line per planned destination action, and a final status line. Destination action lines include the bundle path, destination id, destination backend, action, outputs, and reason. Actions include:
|
||||
|
||||
- `publish_new`: destination has no managed state and is empty.
|
||||
- `replace_older`: destination state is older than the source manifest.
|
||||
- `force_replace`: `--force` requested a supported destructive replacement.
|
||||
- `skip_same`: destination state already matches the source manifest.
|
||||
- `skip_destination_newer`: destination state is newer than the source manifest.
|
||||
- `error`: planning or execution failed for that destination.
|
||||
|
||||
The command exits non-zero if any destination fails. Independent later destinations are still attempted.
|
||||
|
||||
The final status line includes counters for `publish_new`, `replace_older`, `force_replace`, skipped destinations, failures, and whether the run was a dry run.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Use `validate` before publication when a producer has written a new bundle. Use `inspect` to confirm normalized ids, timestamps, digests, file paths, and file sizes.
|
||||
|
||||
For symptom-oriented recovery steps, see [troubleshooting](troubleshooting.md). For destination state and retry behavior, see [operations](operations.md). For config fields and defaults, see [configuration](config.md).
|
||||
221
docs/config.md
Normal file
221
docs/config.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Config File Location
|
||||
|
||||
`distributor run --config <path>` loads the YAML config at the provided path.
|
||||
|
||||
If `--config` is omitted, `run` uses:
|
||||
|
||||
```text
|
||||
/usr/local/etc/distributor/config.yml
|
||||
```
|
||||
|
||||
Config parsing rejects unknown YAML fields. The executable backends are `local`, `ssh`, and `s3`.
|
||||
|
||||
## Minimal Local Config
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/reports
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
```
|
||||
|
||||
This publishes source files only. It uses the default validation and transfer policies.
|
||||
|
||||
## Production-Oriented Local Config
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/reports
|
||||
validation:
|
||||
on_digest_mismatch: fail
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
```
|
||||
|
||||
## HTML Publication
|
||||
|
||||
To publish generated HTML from Markdown files:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
```
|
||||
|
||||
Sidecar generation writes `report.html` for `report.md`. It does not mutate the source bundle.
|
||||
|
||||
## Reference
|
||||
|
||||
Top level:
|
||||
|
||||
- `secrets.directory`: optional credential secrets directory.
|
||||
- `pipelines`: required non-empty list.
|
||||
|
||||
Pipeline:
|
||||
|
||||
- `id`: required unique slug-like identifier.
|
||||
- `source`: required backend config.
|
||||
- `validation.on_digest_mismatch`: optional; defaults to `fail`; only `fail` is supported.
|
||||
- `destinations`: required non-empty destination list.
|
||||
|
||||
Source backend:
|
||||
|
||||
- `backend`: required.
|
||||
- `path`: required for `local` and `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`; leading and trailing slashes are trimmed.
|
||||
- `region`: optional for `s3`; defaults to `us-east-1`.
|
||||
- `force_path_style`: optional for `s3`; defaults to `true`. Set `false` only for services that require virtual-host addressing.
|
||||
- `credentials.access_key_id_env`: optional S3 credential environment variable name.
|
||||
- `credentials.secret_access_key_env`: optional S3 credential environment variable name.
|
||||
|
||||
Destination:
|
||||
|
||||
- `id`: required unique slug-like identifier within the pipeline.
|
||||
- Backend fields: same accepted shape as source backends, with destination fields at the destination level.
|
||||
- `publish`: optional; defaults to source-only publication.
|
||||
- `transform`: required only for generated HTML publication.
|
||||
- `transfer`: optional; defaults described below.
|
||||
|
||||
Accepted backend names:
|
||||
|
||||
- `local`: executable; requires `path`.
|
||||
- `ssh`: executable; requires `host` and `path`.
|
||||
- `s3`: executable; requires `endpoint` and `bucket`.
|
||||
|
||||
## 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. During `run --dry-run`, new host keys are accepted only for the current connection and are not persisted.
|
||||
- `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.
|
||||
|
||||
## S3 Backend
|
||||
|
||||
S3 uses the AWS SDK for Go v2 and supports S3-compatible endpoints:
|
||||
|
||||
```yaml
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
`endpoint` and `bucket` are required. `prefix` is an optional backend root; it is treated as an object-key prefix, not a real directory. Prefixes must be clean slash-separated paths after trimming leading and trailing slashes. `http://` endpoints are allowed for explicitly configured local development or local S3-compatible test services.
|
||||
|
||||
If either credential environment variable name is configured, both must be configured and both referenced variables must resolve to non-empty values through the real process environment or `secrets.directory`. Explicit credentials take precedence over the AWS SDK default credential chain. If credential environment variable names are omitted, the SDK default credential chain is used and `secrets.directory` values are not injected into the process environment.
|
||||
|
||||
Publish policy:
|
||||
|
||||
- `publish.source`: publish source artifacts.
|
||||
- `publish.html`: publish generated HTML artifacts from Markdown source files.
|
||||
|
||||
At least one output type must be enabled. When `publish.html` is true, `transform.markdown_to_html.enabled` must be `true` and `transform.markdown_to_html.mode` must be `sidecar`.
|
||||
|
||||
Transfer policy:
|
||||
|
||||
- `transfer.on_destination_same`: `skip` or `fail`; defaults to `skip`.
|
||||
- `transfer.on_destination_older`: `replace` or `fail`; defaults to `replace`.
|
||||
- `transfer.on_destination_newer`: `skip`, `replace`, or `fail`; defaults to `skip`.
|
||||
- `transfer.on_conflict`: `fail` or `replace`; defaults to `fail`.
|
||||
|
||||
`replace` for `on_destination_newer` and `on_conflict` is honored only when `run --force` is used for that invocation. Force is CLI-only; there is no persistent config field that enables forced replacement by default.
|
||||
|
||||
## Defaults
|
||||
|
||||
Defaults are applied after YAML decoding and before validation:
|
||||
|
||||
- `validation.on_digest_mismatch: fail`
|
||||
- SSH `port: 22`
|
||||
- SSH `host_key_policy: accept-new`
|
||||
- S3 `region: us-east-1`
|
||||
- S3 `force_path_style: true`
|
||||
- `publish.source: true`
|
||||
- `publish.html: false`
|
||||
- `transfer.on_destination_same: skip`
|
||||
- `transfer.on_destination_older: replace`
|
||||
- `transfer.on_destination_newer: skip`
|
||||
- `transfer.on_conflict: fail`
|
||||
|
||||
## Secrets
|
||||
|
||||
Do not put literal secrets in config files. `secrets.directory` lets deployments provide credential values as files:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
directory: /run/secrets/distributor
|
||||
```
|
||||
|
||||
Each regular file in the directory becomes an internal credential environment value named by the filename. Valid filenames must match `[A-Za-z_][A-Za-z0-9_]*`. Directories are ignored, and symlinks to regular files are followed. Exactly one trailing LF or CRLF is trimmed from each file; other whitespace is preserved.
|
||||
|
||||
The resolver checks the real process environment first, then the secrets directory. If both define the same variable with different values, `run` prints a warning with the variable name and uses the real environment value. Secret values are not printed. The process environment is not modified, so SDK default credential chains see only real environment variables.
|
||||
|
||||
S3 credentials may name environment variables:
|
||||
|
||||
- `credentials.access_key_id_env`
|
||||
- `credentials.secret_access_key_env`
|
||||
|
||||
## Examples
|
||||
|
||||
Maintained examples live under [examples](../examples/):
|
||||
|
||||
- `local-to-local.yml`: minimal local config.
|
||||
- `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.
|
||||
- `s3-destination.yml`: environment-gated local-to-S3 publication example.
|
||||
55
docs/integrations/markdown.md
Normal file
55
docs/integrations/markdown.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Markdown Integration
|
||||
|
||||
## Purpose
|
||||
|
||||
Markdown-to-HTML is the only implemented external file-format integration. This note documents the renderer behavior that is externally visible in generated destination artifacts.
|
||||
|
||||
## Dependency
|
||||
|
||||
Rendering uses `github.com/yuin/goldmark`. The exact dependency version is pinned in `go.mod`; review that file before changing renderer behavior or diagnosing version-specific output changes.
|
||||
|
||||
## Renderer behavior
|
||||
|
||||
`internal/transform/markdown.New` constructs the renderer with `goldmark.New()` and no project-specific extensions or renderer options.
|
||||
|
||||
For each source bundle file ending in `.md`, the transform reads the Markdown source and generates an HTML sidecar in the same logical directory. The output path replaces the `.md` suffix with `.html`, so `report.md` produces `report.html`. Non-Markdown source files produce no Markdown outputs.
|
||||
|
||||
Raw HTML embedded in Markdown is not passed through by the current renderer behavior. Tests allow Goldmark's disabled-or-escaped raw HTML output forms and reject literal script tags in generated HTML.
|
||||
|
||||
## Wrapper
|
||||
|
||||
Rendered Markdown body HTML is wrapped in a fixed document shell:
|
||||
|
||||
- `<!doctype html>`
|
||||
- `<html lang="en">`
|
||||
- UTF-8 `<meta charset>`
|
||||
- empty `<title>`
|
||||
- `<body>` containing the rendered Markdown body
|
||||
|
||||
The wrapper is deterministic and does not read configuration, templates, CSS, or source manifest metadata.
|
||||
|
||||
## Output metadata
|
||||
|
||||
Generated outputs record:
|
||||
|
||||
- destination path;
|
||||
- source path;
|
||||
- transform id `markdown_to_html`;
|
||||
- SHA-256 digest of the wrapped HTML bytes;
|
||||
- byte size of the wrapped HTML bytes.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Markdown rendering does not mutate source bundles, publish files, write `.distributor.json`, select outputs, or choose transfer actions. Publish planning decides whether generated HTML is selected for a destination.
|
||||
|
||||
Only sidecar output mode is supported for current behavior.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing Markdown renderer behavior, inspect and run:
|
||||
|
||||
```bash
|
||||
go test ./internal/transform/markdown
|
||||
```
|
||||
|
||||
The tests cover sidecar naming, ignored non-Markdown files, raw HTML handling, deterministic output, digest metadata, and size metadata.
|
||||
66
docs/internal/app.md
Normal file
66
docs/internal/app.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Application Orchestration
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/app` owns top-level use cases for `run`, `validate`, and `inspect`. It wires configuration, storage backends, transforms, publish planning, execution, summaries, and notification handoff.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
`Run` accepts a context, optional config path, dry-run flag, force flag, stdout writer, and optional notifier. It loads YAML config, discovers source bundles for each configured pipeline, plans each destination independently, optionally executes publish plans, writes summary output when stdout is supplied, and returns an aggregated error if any destination fails.
|
||||
|
||||
`Validate` and `Inspect` accept a local path. `Validate` discovers and validates bundles. `Inspect` writes bundle metadata and manifest file entries to stdout when provided.
|
||||
|
||||
## Run flow
|
||||
|
||||
The runner:
|
||||
|
||||
1. loads config from the supplied path or `config.DefaultConfigPath`;
|
||||
2. opens the configured source backend;
|
||||
3. discovers validated bundles from the source root;
|
||||
4. opens each destination backend independently;
|
||||
5. builds a publish plan for each bundle and destination;
|
||||
6. prints plan lines and records summary counters;
|
||||
7. executes publish or replacement plans unless dry-run is enabled;
|
||||
8. invokes the notifier after successful publish or replacement actions.
|
||||
|
||||
Destination failures are collected while later destinations continue to run. Source open and source discovery failures stop the run because there are no valid bundles to fan out.
|
||||
|
||||
## Backend and transform wiring
|
||||
|
||||
The app-level backend factory registers local, SSH, and S3 backends for execution. S3 explicit credential references are resolved through the config environment resolver before adapter construction.
|
||||
|
||||
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.
|
||||
|
||||
## Dry-run behavior
|
||||
|
||||
Dry-run still loads config, opens backends, discovers bundles, inspects destinations, resolves transforms, and builds publish plans. It does not write destination outputs, write `.distributor.json`, delete managed outputs, perform forced prefix deletion, or notify.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
`Run` returns immediately for config loading errors, context cancellation before work starts, source open errors, and source discovery errors. Per-destination backend, planning, execution, and notification errors are aggregated into one run error after remaining destinations have been attempted.
|
||||
|
||||
Run diagnostics include pipeline id, destination id, destination backend, and bundle path for destination-scoped failures. Source open and discovery failures include the source backend.
|
||||
|
||||
Stdout write errors are returned immediately because the caller's requested output stream can no longer be trusted.
|
||||
|
||||
## Boundaries
|
||||
|
||||
`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 currently belongs to `Run`.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing app orchestration, inspect tests under:
|
||||
|
||||
- `internal/app`
|
||||
- `internal/cli`
|
||||
- `internal/publish`
|
||||
|
||||
## Invariants
|
||||
|
||||
- One source fans out to each destination independently.
|
||||
- Destination failures do not prevent later destinations from being planned.
|
||||
- Dry-run must not mutate destination storage or invoke notifications.
|
||||
- Concrete backend and transform registration stays at the app layer.
|
||||
- The default notifier is `notify.Noop`.
|
||||
53
docs/internal/bundle.md
Normal file
53
docs/internal/bundle.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Bundles
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/bundle` parses, discovers, and validates source bundles through the storage interface.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
Input is a backend-rooted directory tree containing one or more `manifest.json` files. Output is a deterministic list of validated bundles with relative bundle paths and normalized manifest data.
|
||||
|
||||
## Manifest behavior
|
||||
|
||||
The source manifest requires:
|
||||
|
||||
- `schema_version: 1`
|
||||
- `id`
|
||||
- `digest`
|
||||
- `created`
|
||||
- non-empty `files`
|
||||
|
||||
Each file requires `path`, `sha256`, and `size`. Digests must use lowercase `sha256:<64 hex>` format. `created` must parse as RFC3339.
|
||||
|
||||
## Validation
|
||||
|
||||
`ValidateManifest` owns normalized source manifest semantics: schema version, id, digest format, timestamp presence, file list presence, source path safety, duplicate file paths, reserved paths, file digest format, non-negative file sizes, and the top-level bundle digest.
|
||||
|
||||
Storage-backed bundle validation additionally checks file existence, regular-file type, file size, and per-file SHA-256.
|
||||
|
||||
The bundle digest is SHA-256 of a deterministic JSON array of file records in manifest order with fields `path`, `sha256`, and `size`.
|
||||
|
||||
## Discovery
|
||||
|
||||
Discovery walks a storage backend beneath a source root, finds `manifest.json` files, sorts bundle paths lexically, and rejects nested manifests.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Manifest parsing and validation fail before destination planning. Storage-backed validation fails when listed files are missing, are not regular files, have unexpected sizes, have unexpected SHA-256 digests, or when a source bundle includes unsafe or reserved paths.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Bundle code uses `internal/storage` and does not import concrete adapters. CLI local path support is wired in `internal/app`.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing bundle behavior, inspect tests under `internal/bundle`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `manifest.json` is the only source bundle contract.
|
||||
- Source file paths must stay relative to the bundle root.
|
||||
- The top-level bundle digest is derived from manifest file records in order.
|
||||
- Discovery order is lexical and deterministic.
|
||||
- Nested manifests are rejected.
|
||||
79
docs/internal/config.md
Normal file
79
docs/internal/config.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Configuration Internals
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/config` defines YAML-backed configuration structs, defaulting, and validation for distributor pipelines.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
Input is a YAML file containing optional `secrets` and required `pipelines`. Output is a `Config` value with defaults applied and validation completed. Load failures include the config path and whether the failure occurred during file loading, YAML parsing, or validation.
|
||||
|
||||
## Loading flow
|
||||
|
||||
`LoadFile` opens the requested path, decodes YAML with known-field checking enabled, applies defaults, and validates the result. The app uses `DefaultConfigPath` when the CLI does not supply a config path.
|
||||
|
||||
Known-field checking rejects misspelled or unknown YAML keys before defaults and validation run.
|
||||
|
||||
`LoadFile` does not read secret files. `Run` loads the configured secrets directory after config validation and before backend construction.
|
||||
|
||||
## Defaults
|
||||
|
||||
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`;
|
||||
- `transfer.on_destination_newer` defaults to `skip`;
|
||||
- `transfer.on_conflict` defaults to `fail`.
|
||||
|
||||
## Validation responsibilities
|
||||
|
||||
Validation requires at least one pipeline, slug-like unique pipeline ids, one source per pipeline, at least one destination, slug-like unique destination ids within each pipeline, backend-specific required fields, valid validation policy, valid publish and transform combinations, and valid transfer actions.
|
||||
|
||||
Transfer validation accepts `replace` for `on_destination_newer` and `on_conflict`, but publish planning honors those destructive actions only when the current run explicitly requests force.
|
||||
|
||||
`ValidatePublishTransformPolicy` is shared with publish planning so destination policy combinations are checked consistently. Publishing HTML requires an enabled Markdown-to-HTML transform in `sidecar` mode. A publish policy must select source output, HTML output, or both.
|
||||
|
||||
## Executable support boundary
|
||||
|
||||
Config validation accepts `local`, `ssh`, and `s3` backend shapes. Runtime execution opens all three through `internal/app`.
|
||||
|
||||
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`.
|
||||
|
||||
S3 config requires `endpoint` and `bucket`, normalizes optional `prefix`, defaults `region` to `us-east-1`, and defaults omitted `force_path_style` to `true` while preserving explicit `false`.
|
||||
|
||||
## Secrets and credential resolution
|
||||
|
||||
`secrets.directory` points to a directory of credential files. `LoadSecretEnvironment` reads regular files and symlinks to regular files, rejects invalid filenames, trims exactly one trailing LF or CRLF, and returns an `Environment` resolver plus conflict metadata.
|
||||
|
||||
The resolver checks the real process environment first and loaded secret values second. Differing process/secret conflicts are reported by variable name only. The resolver does not mutate `os.Environ`; default SDK credential chains continue to see only real process environment values.
|
||||
|
||||
Credential-consuming backend wiring should resolve explicit credential environment variable references through `Environment.ResolveCredentials` or the same resolver pattern instead of calling `os.Getenv` directly.
|
||||
|
||||
The user-facing configuration reference is `docs/config.md`; this file documents package behavior for maintainers.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Load errors wrap the underlying file, YAML, or validation error with context. Validation collects all detected field errors into one error value instead of stopping at the first invalid field.
|
||||
|
||||
Unsupported backend names fail validation. Accepted backend names without runtime execution support fail later during app backend opening.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing config behavior, inspect:
|
||||
|
||||
- `internal/config/load_test.go`
|
||||
- `internal/config/validate_test.go`
|
||||
- example-loading coverage in `internal/config`
|
||||
- user-facing examples under `examples/`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Defaults are applied before validation.
|
||||
- Unknown YAML fields are rejected.
|
||||
- `docs/config.md` remains the canonical user-facing config reference.
|
||||
- Runtime backend execution support is not inferred from config validation support.
|
||||
- New user-visible config behavior must be covered by tests and docs in the same change.
|
||||
35
docs/internal/notify.md
Normal file
35
docs/internal/notify.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Notify
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/notify` defines the internal notification interface used by the application runner.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
Input is a notification event containing pipeline id, destination id, bundle id, bundle path, action, and output metadata. The interface returns an error so app orchestration can treat notification failures as destination failures.
|
||||
|
||||
## Current behavior
|
||||
|
||||
The implemented notifier is a no-op. It is invoked only after a successful publish or replacement. Dry-run, skipped destinations, and failed destinations do not invoke it.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
`notify.Noop` always succeeds unless the context is already canceled. If a configured notifier returns an error, `internal/app` records that destination as failed and continues with remaining destinations.
|
||||
|
||||
## Boundaries
|
||||
|
||||
External notification adapters and user-facing notification configuration are outside current behavior.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing notification behavior, inspect:
|
||||
|
||||
- `internal/notify`
|
||||
- `internal/app/run_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Notifications are emitted only after successful publish or replacement execution.
|
||||
- Dry-run never notifies.
|
||||
- Skipped and failed destinations never notify.
|
||||
- The default app notifier is `notify.Noop`.
|
||||
44
docs/internal/publish.md
Normal file
44
docs/internal/publish.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Publish
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/publish` plans and executes publication for one validated source bundle and one destination.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, publish policy, transform policy, transformer resolver, transfer policy, destination bundle path, existing destination state, and whether explicit force was requested for the current run.
|
||||
|
||||
Output is a plan with an action, reason, and selected source or generated outputs. Execution writes selected source files, generated files, and `.distributor.json` for publish or replacement actions.
|
||||
|
||||
## Actions
|
||||
|
||||
Supported actions are `publish_new`, `replace_older`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, and `fail_unmanaged`.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Planning fails when request fields are incomplete, publish and transform policies are invalid, selected outputs collide, HTML output is requested without Markdown inputs, destination state is invalid, destination content is unmanaged without force, or transfer policy maps the comparison outcome to failure.
|
||||
|
||||
Execution fails if a write, delete, state serialization, or context check fails. Outputs written during a failed publish attempt are cleaned up through managed deletion where possible.
|
||||
|
||||
## Boundaries
|
||||
|
||||
The current implementation publishes source files and Markdown-to-HTML sidecar outputs. Backend behavior is supplied through `internal/storage`; app runtime currently supplies local, SSH, and S3 backends.
|
||||
|
||||
The package uses `internal/state` for destination comparison, `internal/storage` for IO, and the shared `internal/config` publish/transform policy helper for request validation. It resolves transforms through a narrow resolver supplied by the caller; concrete transform registration is owned by the app layer. It does not parse CLI flags or load config files.
|
||||
|
||||
## Safety
|
||||
|
||||
Normal replacement deletes only outputs recorded in existing destination state plus `.distributor.json`. Forced replacement deletes the bounded destination bundle path before writing outputs and state. Failed writes trigger cleanup of outputs written during the failed attempt where practical.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing publish behavior, inspect tests under `internal/publish` and run tests under `internal/app`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Publish planning is deterministic for the same source, destination state, policies, and transform outputs.
|
||||
- Normal replacement deletes only managed paths recorded in existing state plus `.distributor.json`.
|
||||
- Forced replacement is explicit per run and deletes only within the destination bundle path.
|
||||
- Publish execution writes destination state after selected outputs are written.
|
||||
- Transform implementations are resolved through an interface supplied by the caller.
|
||||
- Unmanaged destination content is overwritten only by explicit forced replacement.
|
||||
52
docs/internal/state.md
Normal file
52
docs/internal/state.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Destination State
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/state` parses, validates, writes, and compares `.distributor.json` destination state.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
Input is JSON destination state plus the current source manifest, pipeline id, destination id, and whether the destination path has unmanaged content. Output is a deterministic comparison outcome and reason.
|
||||
|
||||
## State behavior
|
||||
|
||||
`.distributor.json` requires:
|
||||
|
||||
- `schema_version: 1`
|
||||
- `pipeline_id`
|
||||
- `destination_id`
|
||||
- `published_at`
|
||||
- `source.manifest`
|
||||
- `outputs`
|
||||
|
||||
`distributor_version` is optional diagnostic metadata. `published_at` parses as RFC3339 and distributor-written state serializes it as RFC3339 UTC.
|
||||
|
||||
The embedded `source.manifest` is validated with the same source manifest rules used by `internal/bundle`.
|
||||
|
||||
## Outputs
|
||||
|
||||
Each output records `path`, `kind`, `source_path`, `sha256`, and `size`. Supported output kinds are `source` and `generated`. Generated outputs require `transform`.
|
||||
|
||||
## Comparison
|
||||
|
||||
Comparison outcomes cover absent destination state, unmanaged destination content, invalid state, pipeline or destination mismatch, same source manifest, older destination source, newer destination source, same-created digest conflict, and different source id conflict.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Invalid JSON, invalid state schema, invalid embedded source manifests, unsafe output paths, unsupported output kinds, missing generated-output transform names, and mismatched pipeline or destination ids produce comparison outcomes that publish planning can turn into fail actions. Supported identity and source-manifest conflicts can become forced replacement only when publish planning receives explicit force and compatible transfer policy.
|
||||
|
||||
## Boundaries
|
||||
|
||||
This package does not publish files, delete files, inspect storage backends, or choose transfer policy actions. Publish planning consumes these comparison outcomes later.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing destination state behavior, inspect tests under `internal/state`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `.distributor.json` is the destination sentinel and state record.
|
||||
- Embedded source manifests use the same validation rules as source bundles.
|
||||
- Generated outputs always record a transform id.
|
||||
- Comparison returns outcomes and reasons; it does not mutate storage.
|
||||
- `distributor_version` is diagnostic metadata, not a comparison key.
|
||||
62
docs/internal/storage.md
Normal file
62
docs/internal/storage.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Storage
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/storage` defines backend-rooted logical file access for core packages. Callers use slash-separated paths relative to a configured backend root.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
The storage interface supports byte reads, stream reads, byte writes, stream writes, exact metadata lookup, traversal, destination emptiness checks, guarded managed deletion, and bounded prefix deletion for explicit forced replacement.
|
||||
|
||||
Entries report a logical path, type, and size when available. Entry types are `file`, `directory`, `symlink`, and `other`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
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`. The SSH/SFTP adapter lives in `internal/adapters/ssh`. The S3-compatible adapter lives in `internal/adapters/s3`. 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
|
||||
|
||||
Logical file paths must be non-empty, relative, clean, slash-separated, and must not contain `.` or `..` segments or backslashes. Prefix paths follow the same rules, except an empty prefix means the backend root.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Storage errors use typed categories such as not found, already exists, invalid path, conflict, permission, temporary, unsupported, and unknown. Callers should use helper predicates rather than matching error strings.
|
||||
|
||||
Backends may wrap implementation-specific errors, but callers should receive storage errors where practical. Traversal can stop cleanly with `ErrStopWalk`.
|
||||
|
||||
## Deletion
|
||||
|
||||
`DeleteManagedBundle` may delete listed managed outputs plus `.distributor.json`.
|
||||
|
||||
`DeletePrefix` removes content at and below a logical prefix for explicit forced replacement. It must not delete above the requested prefix or above the configured backend root.
|
||||
|
||||
## Local, SSH, S3, 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 or explicit bounded prefixes.
|
||||
|
||||
The S3 adapter maps logical paths to object keys below a configured bucket and optional prefix. It uses the AWS SDK for Go v2, treats prefixes as object trees, requires exact objects for `Stat`, paginates traversal, applies conservative overwrite checks with `HeadObject`, infers basic content types, and limits deletion to managed target objects or explicit bounded object-key prefixes.
|
||||
|
||||
The fake backend is an in-memory implementation for package tests. It is not registered for runtime use.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing storage behavior, inspect tests under:
|
||||
|
||||
- `internal/storage`
|
||||
- `internal/storage/fake`
|
||||
- `internal/adapters/local`
|
||||
- `internal/adapters/ssh`
|
||||
- `internal/adapters/s3`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Core packages depend on `internal/storage`, not concrete adapters.
|
||||
- Logical paths are slash-separated and confined to the backend root.
|
||||
- `storage.List` uses backend traversal and returns deterministic entries.
|
||||
- Managed deletion is limited to recorded outputs plus `.distributor.json`.
|
||||
- Prefix deletion is limited to the requested logical prefix.
|
||||
- Runtime backend registration is owned by `internal/app`.
|
||||
46
docs/internal/transform.md
Normal file
46
docs/internal/transform.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Transform
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/transform` defines generated publication artifacts. `internal/transform/markdown` implements Markdown-to-HTML sidecar generation.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
Inputs are a validated source bundle and source backend. Outputs include destination path, source path, transform id, generated bytes, SHA-256, and size.
|
||||
|
||||
## Registry
|
||||
|
||||
`internal/transform` defines the transform interface and registry. The app layer registers the Markdown implementation; publish planning receives only a resolver.
|
||||
|
||||
## Markdown behavior
|
||||
|
||||
Markdown files ending in `.md` generate `.html` files in the same logical directory. Non-Markdown files do not generate outputs. Raw HTML embedded in Markdown is not passed through by the renderer.
|
||||
|
||||
Generated HTML is deterministic for the same source content and transform configuration.
|
||||
|
||||
See `docs/integrations/markdown.md` for the Goldmark integration contract.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Transform resolution fails when a requested transform id is not registered. Markdown rendering fails when the source file cannot be read or rendered. Publish planning fails when HTML output is requested and the selected transform produces no outputs for a bundle.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Transforms do not publish files, mutate source bundles, or write destination state. Publish planning selects and writes transform outputs.
|
||||
|
||||
The app layer owns default transform registration. The transform package does not import concrete transform implementations.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing transform behavior, inspect tests under:
|
||||
|
||||
- `internal/transform`
|
||||
- `internal/transform/markdown`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Source bundle files are never mutated by transforms.
|
||||
- Generated outputs record destination path, source path, transform id, SHA-256, and size.
|
||||
- Markdown sidecar naming changes only the `.md` extension to `.html`.
|
||||
- Non-Markdown source files do not generate Markdown outputs.
|
||||
- Transform registration stays outside publish planning.
|
||||
149
docs/operations.md
Normal file
149
docs/operations.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Distributor Operations
|
||||
|
||||
## Normal Workflow
|
||||
|
||||
Validate a source bundle:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate examples/source-bundle
|
||||
```
|
||||
|
||||
Preview a local publication:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
|
||||
```
|
||||
|
||||
Run the local publication:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
Run the local HTML publication:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-html.yml
|
||||
```
|
||||
|
||||
Preview local fan-out publication:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
Preview an environment-gated S3 destination config after editing it for an S3-compatible endpoint and bucket you control:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/s3-destination.yml --dry-run
|
||||
```
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
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 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.
|
||||
|
||||
S3 backends use the configured bucket plus optional `prefix` as the backend root. Source bundle discovery and destination bundle paths are relative to that object-key prefix. Prefixes are object-key prefixes, not real directories.
|
||||
|
||||
## Destination State
|
||||
|
||||
Each published destination bundle contains `.distributor.json`. This file is the managed sentinel and destination state record. It stores:
|
||||
|
||||
- pipeline and destination identity;
|
||||
- publication timestamp;
|
||||
- source manifest used for publication;
|
||||
- copied source output metadata;
|
||||
- generated output metadata.
|
||||
|
||||
`manifest.json` from the source bundle is not copied as destination state.
|
||||
|
||||
Do not edit `.distributor.json` by hand during normal operation. If it is missing or invalid while destination files remain, `distributor` treats the destination as unmanaged or conflicted.
|
||||
|
||||
## Dry Runs
|
||||
|
||||
`--dry-run` loads and validates config, discovers source bundles, inspects destination state, plans outputs, and prints summary lines. It does not write output files, destination state, or SSH `known_hosts` entries.
|
||||
|
||||
Dry-run output is useful before publishing to confirm actions such as `publish_new`, `replace_older`, `force_replace`, `skip_same`, and `skip_destination_newer`.
|
||||
|
||||
Destination action lines include the destination backend, so mixed local, SSH, and S3 fan-out runs can be audited before publication.
|
||||
|
||||
## Retry and Replacement Behavior
|
||||
|
||||
If a destination has matching `.distributor.json`, publication skips it as already published.
|
||||
|
||||
If destination state is older than the source manifest and transfer policy allows replacement, publication deletes only managed outputs recorded in `.distributor.json` plus the state file, then writes the new outputs and state.
|
||||
|
||||
If destination state is newer than the source manifest, the default behavior is to skip. If destination state has the same source id and created timestamp but a different digest, publication fails as a conflict.
|
||||
|
||||
If a destination path has files but no valid `.distributor.json`, publication fails as unmanaged content unless the current run explicitly uses `--force`.
|
||||
|
||||
## Force Workflow
|
||||
|
||||
Use `--force` only after a dry run shows the intended `force_replace` action:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --force
|
||||
go run ./cmd/distributor run --config <config-path> --force
|
||||
```
|
||||
|
||||
Forced replacement can overwrite unmanaged non-empty destination paths. Destination state conflicts require `transfer.on_conflict: replace` plus `--force`. Newer destination state requires `transfer.on_destination_newer: replace` plus `--force`.
|
||||
|
||||
Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete above that bundle path. For S3 destinations, deletion is constrained to the configured bucket and prefix plus the destination bundle prefix. Force is per run only and has no config default.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
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.
|
||||
|
||||
Errors include the pipeline id, destination id, destination backend, and bundle path where applicable.
|
||||
|
||||
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. During `--dry-run`, unknown host keys may be accepted for the current connection but are not written to `known_hosts`; a later non-dry-run may persist the same key. 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.
|
||||
|
||||
## S3 Operation Notes
|
||||
|
||||
S3 execution uses the AWS SDK for Go v2. Configure `endpoint`, `bucket`, optional `prefix`, optional `region`, and optional explicit credential environment variable names.
|
||||
|
||||
When explicit credential env names are configured, both variables must resolve to non-empty values through the real process environment or `secrets.directory`. When they are omitted, the AWS SDK default credential chain is used as-is.
|
||||
|
||||
Normal replacement and failed-write cleanup delete only managed output objects recorded in `.distributor.json` plus the state object. Forced replacement deletes objects under the bounded destination bundle prefix. Distributor does not manage bucket versioning or delete markers.
|
||||
|
||||
## Secrets Directory
|
||||
|
||||
Configure `secrets.directory` when credential values should come from mounted files, such as deployment secrets:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
directory: /run/secrets/distributor
|
||||
```
|
||||
|
||||
The directory is loaded during `run` before any source or destination backend is opened. If the directory is missing, unreadable, or contains an invalid secret filename, the run fails before publication work starts.
|
||||
|
||||
Real process environment values take precedence over files with the same name. If the values differ and stdout is enabled, `run` prints a warning naming the ignored secret file variable without printing either value. The process environment is not changed.
|
||||
|
||||
## Caveats
|
||||
|
||||
External notification adapters are unavailable. Force overwrite behavior is available only through the explicit `run --force` workflow.
|
||||
|
||||
For symptom-oriented fixes, see [troubleshooting](troubleshooting.md). For config details, see [configuration](config.md). For command syntax, see [CLI](cli.md).
|
||||
@@ -8,7 +8,7 @@ This document defines the development principles for `distributor`. It is inward
|
||||
|
||||
Producer applications generate manifested bundles. `distributor` discovers those bundles, validates them, optionally derives publication artifacts such as HTML, and publishes selected source and generated artifacts to one or more configured destinations.
|
||||
|
||||
`distributor` does not generate domain reports, interpret domain-specific report content, run producer pipelines, edit reports, or act as a CMS. Weather reports, D&D recaps, calendar summaries, email digests, and future report types should all enter `distributor` through the same bundle contract.
|
||||
`distributor` does not generate domain reports, interpret domain-specific report content, run producer pipelines, edit reports, or act as a CMS. Weather reports, D&D recaps, calendar summaries, email digests, and additional report types should all enter `distributor` through the same bundle contract.
|
||||
|
||||
## Project Shape
|
||||
|
||||
@@ -16,7 +16,7 @@ Default to a small, explicit, dependency-light Go application. Keep the design m
|
||||
|
||||
Business logic should live outside CLI, transport, and external-adapter packages. The core application should reason in terms of pipelines, bundles, destination state, transforms, and publish plans—not S3 SDK calls, SFTP sessions, shell commands, or filesystem details.
|
||||
|
||||
The core workflow is:
|
||||
The current core workflow is:
|
||||
|
||||
1. load configured pipelines;
|
||||
2. open the source backend;
|
||||
@@ -28,13 +28,13 @@ The core workflow is:
|
||||
8. optionally transform Markdown to HTML for that destination;
|
||||
9. publish selected source and generated artifacts;
|
||||
10. write `.distributor.json` as the destination sentinel/state file;
|
||||
11. run the notification stage, which is a no-op in the MVP.
|
||||
11. run the notification hook, which is a no-op in the MVP.
|
||||
|
||||
## Pipeline Model
|
||||
|
||||
A pipeline has exactly one source and one or more destinations.
|
||||
|
||||
The source is discovered and validated once. Each destination has independent backend configuration, publication policy, transform policy, replacement behavior, state, and future notification behavior.
|
||||
The source is discovered and validated once. Each destination has independent backend configuration, publication policy, transform policy, replacement behavior, state, and notification behavior.
|
||||
|
||||
The pipeline model is fan-out by design:
|
||||
|
||||
@@ -45,7 +45,7 @@ source bundle
|
||||
-> destination C: source files + HTML
|
||||
```
|
||||
|
||||
Destination-specific behavior must not leak back into the source bundle contract. A producer should not need to know whether a bundle will be published to local storage, SSH/SFTP, S3, a static site, email, RSS, or a future notification channel.
|
||||
Destination-specific behavior must not leak back into the source bundle contract. A producer should not need to know whether a bundle will be published to local storage, another storage backend, a static site, email, RSS, or another notification channel.
|
||||
|
||||
## Source Bundle Contract
|
||||
|
||||
@@ -166,13 +166,13 @@ For example, one destination may publish source files only as a long-term archiv
|
||||
|
||||
## Backend Abstraction
|
||||
|
||||
Sources and destinations use the same storage abstraction. Local filesystem, SSH/SFTP, and S3-compatible object storage are peer backends. Any backend may appear as a source or a destination unless a specific limitation is documented.
|
||||
Sources and destinations use the same storage abstraction. Current runtime execution uses the local filesystem, SSH/SFTP, and S3-compatible backends. Additional storage backends should be peer implementations behind the same interface, and any backend-specific execution limitation must be documented.
|
||||
|
||||
Application logic must interact with storage through internal backend interfaces. Backend-specific behavior belongs in adapter packages. Pipeline, bundle, state, publish, and transform packages must not import S3, SSH/SFTP, or filesystem adapter implementation details.
|
||||
Application logic must interact with storage through internal backend interfaces. Backend-specific behavior belongs in adapter packages. Pipeline, bundle, state, publish, and transform packages must not import service-specific or filesystem adapter implementation details.
|
||||
|
||||
Adapters should be thin. Backend adapters should implement storage operations and translate backend-specific errors, but should not make bundle comparison, transform, routing, or replacement decisions.
|
||||
|
||||
SSH support should prefer a native SFTP implementation over shelling out to `ssh`, `scp`, or `rsync`, unless a later design document records a reason to differ.
|
||||
Remote file-transfer support should prefer native protocol implementations over shelling out, unless a later design document records a reason to differ.
|
||||
|
||||
## Dependency Policy
|
||||
|
||||
@@ -184,7 +184,7 @@ Avoid dependencies for small conveniences. Do not let external dependency types
|
||||
|
||||
## Package Layout
|
||||
|
||||
Use this layout unless the project has a documented reason to differ:
|
||||
Use this current layout unless the project has a documented reason to differ:
|
||||
|
||||
- `cmd/distributor`: application entrypoint only.
|
||||
- `internal/app`: application orchestration and top-level use cases.
|
||||
@@ -202,6 +202,8 @@ Use this layout unless the project has a documented reason to differ:
|
||||
- `internal/notify`: notification interface and MVP no-op notifier.
|
||||
- `internal/logging`: logging setup and shared logging helpers.
|
||||
|
||||
New storage adapters should live under `internal/adapters/<name>` and stay thin.
|
||||
|
||||
Package-private implementation constants may live near the package that owns them, preferably in `constants.go` when useful.
|
||||
|
||||
## Configuration
|
||||
@@ -244,9 +246,9 @@ Each major stage should have an explicit input/output contract:
|
||||
- publish execution;
|
||||
- notification.
|
||||
|
||||
If users can select backends, transforms, notifiers, or future renderers, selection should go through a registry or equivalent mechanism rather than scattered conditionals.
|
||||
If users can select backends, transforms, notifiers, or renderers, selection should go through a registry or equivalent mechanism rather than scattered conditionals.
|
||||
|
||||
The orchestrator should be able to plan, dry-run, and execute configured pipelines. Dry-run behavior should be first-class because the application may delete, overwrite, or publish files to remote storage.
|
||||
The orchestrator should be able to plan, dry-run, and execute configured pipelines. Dry-run behavior should be first-class because the application may delete, overwrite, or publish files.
|
||||
|
||||
## Embedded Assets
|
||||
|
||||
@@ -264,7 +266,7 @@ Skip and no-op decisions should be logged at an appropriate level so operators c
|
||||
|
||||
## Context, Timeouts, and Cancellation
|
||||
|
||||
Long-running operations should accept `context.Context`. Storage operations, SSH/SFTP sessions, S3 requests, transforms, and multi-stage workflows should respect cancellation and timeouts.
|
||||
Long-running operations should accept `context.Context`. Storage operations, service requests, transforms, and multi-step workflows should respect cancellation and timeouts.
|
||||
|
||||
## State, Files, and Safety
|
||||
|
||||
@@ -272,9 +274,11 @@ If the application writes durable state, writes should be atomic where practical
|
||||
|
||||
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
|
||||
|
||||
`distributor` must never perform broad deletion against a configured source root or destination root. Destructive replacement may occur only inside a resolved destination bundle path when a valid `.distributor.json` confirms that the path is distributor-managed.
|
||||
`distributor` must never perform broad deletion against a configured source root. Destination deletion must be bounded to the resolved destination bundle path for the current source bundle and backend root.
|
||||
|
||||
Replacement must be narrow, logged, test-covered, and configurable. Prefer deleting files recorded in `.distributor.json` and known generated outputs rather than blindly deleting parent directories. Backend implementations must guard against path traversal, prefix confusion, and accidental root deletion.
|
||||
Normal destructive replacement may occur only when a valid `.distributor.json` confirms that the destination bundle path is distributor-managed. Explicit forced replacement is a per-run CLI workflow for supported conflict and unmanaged-content cases; it must be dry-runnable, clearly reported, and constrained to the destination bundle path.
|
||||
|
||||
Replacement must be narrow, reported, test-covered, and configurable. Prefer normal replacement that deletes files recorded in `.distributor.json` and known generated outputs. Forced replacement may delete a bounded destination bundle prefix only when the operator explicitly requests it. Backend implementations must guard against path traversal, prefix confusion, and accidental deletion above the configured backend root.
|
||||
|
||||
Where practical, publish operations should use staging paths or temporary objects and promote them into place only after validation and transform steps succeed.
|
||||
|
||||
@@ -282,7 +286,7 @@ Where practical, publish operations should use staging paths or temporary object
|
||||
|
||||
Core logic should be testable without real external services. Use fakes, fixtures, or local test doubles for adapters where practical.
|
||||
|
||||
Config examples should be load-tested. Important CLI workflows should have parser or command tests. Stage/module contracts should have focused tests that do not require running the full application unless end-to-end coverage is intentional.
|
||||
Config examples should be load-tested. Important CLI workflows should have parser or command tests. Component contracts should have focused tests that do not require running the full application unless end-to-end coverage is intentional.
|
||||
|
||||
Important tests include:
|
||||
|
||||
@@ -297,13 +301,13 @@ Important tests include:
|
||||
- transform output planning and metadata recording;
|
||||
- dry-run output;
|
||||
- local backend behavior with temporary directories;
|
||||
- fake backend behavior for S3 and SSH/SFTP-facing core logic.
|
||||
- fake backend behavior for storage-facing core logic.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
|
||||
|
||||
When changing architecture, config, CLI behavior, adapters, manifest/state contracts, transform behavior, publish behavior, or stage/module contracts, update the relevant docs and examples in the same change.
|
||||
When changing architecture, config, CLI behavior, adapters, manifest/state contracts, transform behavior, publish behavior, or component contracts, update the relevant docs and examples in the same change.
|
||||
|
||||
The source manifest and destination `.distributor.json` schemas should have canonical documentation once implemented. Example configs should be valid and load-tested where practical.
|
||||
|
||||
@@ -321,4 +325,4 @@ The source manifest and destination `.distributor.json` schemas should have cano
|
||||
- a backup system;
|
||||
- a notification platform.
|
||||
|
||||
It may later support notification adapters, RSS/feed generation, richer HTML templates, or additional transforms, but those features must preserve the core bundle-distribution boundary.
|
||||
Additional notification, feed, template, or transform behavior must preserve the core bundle-distribution boundary.
|
||||
|
||||
@@ -1 +1,208 @@
|
||||
# Not yet implemented
|
||||
# Development Policy
|
||||
|
||||
This document defines the day-to-day development workflow for `distributor`.
|
||||
Use it with `docs/policy/architecture.md` and `docs/policy/documentation.md`.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `cmd/distributor`: executable entrypoint only.
|
||||
- `internal/app`: top-level use cases for `run`, `validate`, and `inspect`.
|
||||
- `internal/cli`: standard-library command parsing, flags, help text, and command wiring.
|
||||
- `internal/config`: YAML configuration structs, loading, defaults, and validation.
|
||||
- `internal/bundle`: source bundle discovery, manifest parsing, digest calculation, and validation.
|
||||
- `internal/state`: destination `.distributor.json` parsing, validation, and comparison.
|
||||
- `internal/storage`: backend interface, registry, logical path rules, typed errors, and shared storage helpers.
|
||||
- `internal/adapters/local`: local filesystem backend.
|
||||
- `internal/adapters/ssh`: SSH/SFTP backend.
|
||||
- `internal/adapters/s3`: S3-compatible object storage backend.
|
||||
- `internal/storage/fake`: in-memory backend for tests.
|
||||
- `internal/publish`: destination inspection, output planning, reconciliation, execution, managed cleanup, and explicit forced replacement.
|
||||
- `internal/transform`: transform interface and registry.
|
||||
- `internal/transform/markdown`: Markdown-to-HTML sidecar transform.
|
||||
- `internal/notify`: notification interface and current no-op notifier.
|
||||
- `internal/testutil`: shared test fixtures. Production code must not import this package.
|
||||
- `docs`: current user, operator, policy, internal, and roadmap documentation.
|
||||
- `examples`: copyable example configs and source bundles.
|
||||
|
||||
Do not create new top-level package families such as `pkg`, `internal/stage`,
|
||||
`internal/modules`, or service-specific adapter directories unless the
|
||||
architecture policy or a current roadmap explicitly calls for them.
|
||||
|
||||
## Common Commands
|
||||
|
||||
Run the full test suite:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Run targeted packages while developing:
|
||||
|
||||
```bash
|
||||
go test ./internal/config
|
||||
go test ./internal/cli ./internal/app
|
||||
go test ./internal/publish ./internal/state
|
||||
go test ./internal/transform/markdown
|
||||
```
|
||||
|
||||
Run the CLI against an example config:
|
||||
|
||||
```bash
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
|
||||
```
|
||||
|
||||
Validate or inspect a local source bundle:
|
||||
|
||||
```bash
|
||||
go run ./cmd/distributor validate examples/source-bundle
|
||||
go run ./cmd/distributor inspect examples/source-bundle
|
||||
```
|
||||
|
||||
If Go cache permissions fail in a restricted environment, use workspace-safe
|
||||
temporary caches:
|
||||
|
||||
```bash
|
||||
GOCACHE=/private/tmp/distributor-gocache GOMODCACHE=/private/tmp/distributor-gomodcache go test ./...
|
||||
```
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- Keep the application small, explicit, and dependency-light.
|
||||
- Prefer package-local helpers over broad abstractions until behavior is shared by multiple packages.
|
||||
- Keep CLI parsing in `internal/cli`; business decisions belong in `internal/app`, `internal/bundle`, `internal/publish`, `internal/state`, and related core packages.
|
||||
- Keep adapter packages thin. Backend-specific filesystem or service behavior belongs in adapters; bundle, state, transform, and publish policy belongs outside adapters.
|
||||
- Preserve public CLI behavior, config semantics, manifest schema, destination state schema, and implemented backend behavior unless the current task explicitly changes them.
|
||||
- Use `storage.DisplayPath`, `storage.StateFileName`, `storage.StatePath`, and `storage.ManagedBundleTargets` instead of duplicating those conventions.
|
||||
- Use `bundle.ValidateManifest` for normalized source manifest semantics, including embedded source manifests in destination state.
|
||||
- Use `config.ValidatePublishTransformPolicy` for publish and transform policy combinations.
|
||||
- Do not import concrete transform implementations from `internal/publish`; app-level wiring owns transform registration.
|
||||
- Do not import `internal/testutil` from production code.
|
||||
|
||||
## Dependency Policy
|
||||
|
||||
The project currently depends on:
|
||||
|
||||
- `gopkg.in/yaml.v3` for YAML configuration loading.
|
||||
- `github.com/yuin/goldmark` for Markdown rendering.
|
||||
- `golang.org/x/crypto/ssh`, `golang.org/x/crypto/ssh/agent`, and `golang.org/x/crypto/ssh/knownhosts` for native SSH support.
|
||||
- `github.com/pkg/sftp` for native SFTP support.
|
||||
- `github.com/aws/aws-sdk-go-v2/...` packages for S3-compatible storage support.
|
||||
|
||||
Add external dependencies only when they materially improve correctness,
|
||||
security, interoperability, or implementation complexity. Avoid dependencies
|
||||
for small conveniences. Do not let dependency-specific types leak across
|
||||
internal package boundaries unless that dependency is the explicit package
|
||||
contract.
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
When adding or changing configuration:
|
||||
|
||||
1. Update `internal/config/config.go` structs and YAML tags.
|
||||
2. Add defaults in `internal/config/defaults.go` only for built-in defaults.
|
||||
3. Add validation in `internal/config/validate.go` with clear field context.
|
||||
4. Update config load and validation tests.
|
||||
5. Update `docs/config.md` in the same change if current user-visible config behavior changes.
|
||||
6. Update examples only with configs that are valid and executable for implemented behavior.
|
||||
|
||||
Config validation may accept fields for roadmap backends before execution
|
||||
support exists, but user-facing docs and examples must clearly state execution
|
||||
support. Runtime executable backends are local, SSH, and S3.
|
||||
|
||||
Credential-consuming code must use the config-owned environment resolver for
|
||||
explicit credential environment variable references. Do not call `os.Getenv`
|
||||
directly for backend credentials, because `secrets.directory` values are
|
||||
intentionally available through the resolver without mutating the process
|
||||
environment.
|
||||
|
||||
## CLI Changes
|
||||
|
||||
The CLI is hand-written with the Go standard library. Do not introduce a CLI
|
||||
framework without a documented reason.
|
||||
|
||||
When adding or changing commands or flags:
|
||||
|
||||
1. Keep parsing and help text in `internal/cli`.
|
||||
2. Keep command work in `internal/app` or a lower-level package.
|
||||
3. Add or update CLI tests in `internal/cli`.
|
||||
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 executes local, SSH, and S3 backends.
|
||||
|
||||
## Storage Backends
|
||||
|
||||
Storage behavior is defined by `internal/storage.Backend` and shared path rules
|
||||
in `internal/storage`.
|
||||
|
||||
When adding a backend:
|
||||
|
||||
1. Implement the storage interface in an adapter package.
|
||||
2. Translate backend-specific errors into storage errors where practical.
|
||||
3. Keep bundle comparison, transform, routing, and replacement policy out of the adapter.
|
||||
4. Register runtime construction through app-level backend factory wiring.
|
||||
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 future backend execution as available until the corresponding
|
||||
adapter package and app wiring exist.
|
||||
|
||||
## Transforms
|
||||
|
||||
Transforms use `internal/transform` interfaces and registry wiring.
|
||||
|
||||
When adding or changing a transform:
|
||||
|
||||
1. Keep the transform implementation in its own package under `internal/transform`.
|
||||
2. Register default runtime transforms from `internal/app`.
|
||||
3. Keep `internal/publish` dependent only on the transform interface or resolver.
|
||||
4. Record deterministic output metadata: path, source path, transform name, digest, and size.
|
||||
5. Add transform tests and app or publish tests for wiring and policy behavior.
|
||||
6. Update `docs/internal/transform.md` and any relevant integration docs for implemented behavior.
|
||||
|
||||
## Tests
|
||||
|
||||
Test close to the behavior being changed:
|
||||
|
||||
- Use package tests for parsing, validation, comparison, planning, and adapter behavior.
|
||||
- Use `internal/app` and `internal/cli` tests for user-facing workflows.
|
||||
- Use `internal/testutil` for shared valid fixtures only; keep edge cases near the package under test.
|
||||
- Run `go test ./...` after cross-package changes or documentation/example changes tied to tests.
|
||||
|
||||
Live integration tests must be opt-in and skipped during normal `go test ./...`
|
||||
unless their required environment variables are set. Test-only environment
|
||||
variables must use this prefix shape:
|
||||
|
||||
```text
|
||||
DISTRIBUTOR_TEST_<BACKEND>_*
|
||||
```
|
||||
|
||||
Examples include `DISTRIBUTOR_TEST_SSH_HOST` and
|
||||
`DISTRIBUTOR_TEST_S3_ENDPOINT`. Do not use production credential variable names
|
||||
for test-only controls.
|
||||
|
||||
## Examples
|
||||
|
||||
Examples under `examples/` must be valid, maintained, and free of secrets.
|
||||
They should be copyable for implemented behavior. Remote examples must use
|
||||
placeholders or environment variables for endpoint and credential material.
|
||||
|
||||
When changing examples:
|
||||
|
||||
1. Keep paths relative to the repository where practical.
|
||||
2. Keep local examples runnable without external services; gate remote examples
|
||||
behind user-provided endpoints and credentials.
|
||||
3. Run `go test ./internal/config` because config tests load examples.
|
||||
4. Update README, CLI, or config docs if links or recommended workflows change.
|
||||
|
||||
## Documentation
|
||||
|
||||
Follow `docs/policy/documentation.md`.
|
||||
|
||||
- Document implemented behavior outside `docs/roadmap/`.
|
||||
- Keep future, planned, or aspirational behavior under `docs/roadmap/`.
|
||||
- Keep `docs/config.md` canonical for user-facing config reference.
|
||||
- Keep `docs/cli.md` canonical for command syntax and workflows.
|
||||
- Keep `docs/operations.md` canonical for operational and recovery behavior.
|
||||
- Keep `docs/internal/` focused on implemented package contracts.
|
||||
- Update docs in the same change as behavior when public behavior, config, CLI, examples, or internal contracts change.
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# Distributor Configuration Roadmap
|
||||
|
||||
This roadmap defines the planned `config.yml` schema for the `distributor` MVP. The goal is to support one-to-many publication pipelines where each pipeline has one source and one or more destinations. Each destination independently controls backend configuration, publication outputs, transform behavior, and replacement policy.
|
||||
|
||||
## Configuration Goals
|
||||
|
||||
The MVP configuration should be:
|
||||
|
||||
- explicit enough to avoid hidden publication behavior;
|
||||
- compact enough for routine self-hosted use;
|
||||
- backend-agnostic at the pipeline layer;
|
||||
- capable of local, SSH/SFTP, and S3-compatible source and destination backends;
|
||||
- ready for future notification adapters without exposing a fake notification feature in the MVP.
|
||||
|
||||
## Top-Level Shape
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
- id: weather-daily
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/weather
|
||||
|
||||
validation:
|
||||
on_digest_mismatch: fail
|
||||
|
||||
destinations:
|
||||
- id: markdown-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: weather/archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
|
||||
- id: static-site
|
||||
backend: ssh
|
||||
uri: ssh://deploy@example.com:22
|
||||
path: /srv/www/weather
|
||||
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
```
|
||||
|
||||
## Pipeline Fields
|
||||
|
||||
Each pipeline must include:
|
||||
|
||||
- `id`: Required stable pipeline identifier.
|
||||
- `source`: Required source backend configuration.
|
||||
- `destinations`: Required non-empty list of destination configurations.
|
||||
|
||||
Optional pipeline-level fields:
|
||||
|
||||
- `validation`: Source validation behavior.
|
||||
- Future: `notifications` or `notify`, when notification adapters are implemented.
|
||||
|
||||
A pipeline has exactly one source and one or more destinations.
|
||||
|
||||
## Pipeline ID Rules
|
||||
|
||||
`pipelines[].id` should:
|
||||
|
||||
- be required;
|
||||
- be unique across the config file;
|
||||
- be stable over time;
|
||||
- use a simple slug-like format, such as `weather-daily` or `dnd-session-recaps`.
|
||||
|
||||
Recommended validation:
|
||||
|
||||
```text
|
||||
^[a-zA-Z0-9][a-zA-Z0-9._-]*$
|
||||
```
|
||||
|
||||
## Source Configuration
|
||||
|
||||
`source` defines the source root where bundles are discovered.
|
||||
|
||||
The source backend may be:
|
||||
|
||||
- `local`;
|
||||
- `ssh`;
|
||||
- `s3`.
|
||||
|
||||
The source is scanned for `manifest.json` files beneath the configured root.
|
||||
|
||||
### Local source
|
||||
|
||||
```yaml
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/weather
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `backend: local`
|
||||
- `path`
|
||||
|
||||
### SSH source
|
||||
|
||||
```yaml
|
||||
source:
|
||||
backend: ssh
|
||||
uri: ssh://reports@example.com:22
|
||||
path: /var/spool/distributor/weather
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `backend: ssh`
|
||||
- `uri`
|
||||
- `path`
|
||||
|
||||
Recommended authentication behavior:
|
||||
|
||||
- use SSH agent by default;
|
||||
- use local known_hosts validation by default;
|
||||
- support optional key file configuration later if needed;
|
||||
- do not require passwords in YAML.
|
||||
|
||||
Optional future fields:
|
||||
|
||||
```yaml
|
||||
known_hosts: /home/user/.ssh/known_hosts
|
||||
key_file: /home/user/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
### S3 source
|
||||
|
||||
```yaml
|
||||
source:
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: incoming/weather
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `backend: s3`
|
||||
- `endpoint`
|
||||
- `bucket`
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `prefix`
|
||||
- `region`
|
||||
- `force_path_style`
|
||||
- `credentials`
|
||||
|
||||
Credential configuration should prefer environment variables or standard SDK behavior over literal secrets in config.
|
||||
|
||||
## Destination Configuration
|
||||
|
||||
Each destination defines one publication target for a pipeline.
|
||||
|
||||
Required destination fields:
|
||||
|
||||
- `id`
|
||||
- `backend`
|
||||
- backend-specific location fields;
|
||||
- `publish`
|
||||
|
||||
Optional destination fields:
|
||||
|
||||
- `transform`
|
||||
- `transfer`
|
||||
|
||||
Each destination is independently planned and published. A destination may receive source files, generated HTML, or both.
|
||||
|
||||
## Destination ID Rules
|
||||
|
||||
`destinations[].id` should:
|
||||
|
||||
- be required;
|
||||
- be unique within the containing pipeline;
|
||||
- be stable over time;
|
||||
- use a slug-like format.
|
||||
|
||||
Recommended examples:
|
||||
|
||||
- `markdown-archive`
|
||||
- `static-site`
|
||||
- `full-mirror`
|
||||
|
||||
## Local Destination
|
||||
|
||||
```yaml
|
||||
destinations:
|
||||
- id: local-static
|
||||
backend: local
|
||||
path: /srv/www/reports
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `backend: local`
|
||||
- `path`
|
||||
- `publish`
|
||||
|
||||
## SSH Destination
|
||||
|
||||
```yaml
|
||||
destinations:
|
||||
- id: static-site
|
||||
backend: ssh
|
||||
uri: ssh://deploy@example.com:22
|
||||
path: /srv/www/weather
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `backend: ssh`
|
||||
- `uri`
|
||||
- `path`
|
||||
- `publish`
|
||||
|
||||
The MVP should use a native SFTP implementation rather than shelling out to `ssh`, `scp`, or `rsync`.
|
||||
|
||||
## S3 Destination
|
||||
|
||||
```yaml
|
||||
destinations:
|
||||
- id: markdown-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: weather/archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `backend: s3`
|
||||
- `endpoint`
|
||||
- `bucket`
|
||||
- `publish`
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `prefix`
|
||||
- `region`
|
||||
- `force_path_style`
|
||||
- `credentials`
|
||||
|
||||
## Backend Configuration Normalization
|
||||
|
||||
The config loader should normalize backend configuration into internal source and destination backend specs. Pipeline logic should not branch on backend-specific fields.
|
||||
|
||||
Validation should catch:
|
||||
|
||||
- missing backend names;
|
||||
- unsupported backend names;
|
||||
- missing backend-specific required fields;
|
||||
- duplicate pipeline IDs;
|
||||
- duplicate destination IDs within a pipeline;
|
||||
- empty destination lists;
|
||||
- invalid policy values.
|
||||
|
||||
## Publication Policy
|
||||
|
||||
`publish` controls which categories of files are written to a destination.
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `source`: Publish source artifacts listed in `manifest.json`.
|
||||
- `html`: Publish HTML files generated from Markdown source artifacts.
|
||||
|
||||
At least one of `source` or `html` must be true.
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
```
|
||||
|
||||
No implicit HTML transformation should occur. When `publish.html` is true, `transform.markdown_to_html.enabled: true` and `mode: sidecar` are required for the MVP.
|
||||
|
||||
## Transform Configuration
|
||||
|
||||
For MVP, the only supported transform is Markdown to HTML.
|
||||
|
||||
```yaml
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `enabled`: Whether Markdown-to-HTML transform is enabled.
|
||||
- `mode`: Output mode. MVP value: `sidecar`.
|
||||
|
||||
MVP `sidecar` behavior:
|
||||
|
||||
- each listed Markdown source file generates an HTML file with the same base path and `.html` extension;
|
||||
- `report.md` generates `report.html`;
|
||||
- generated files are destination publication artifacts;
|
||||
- source bundles are not mutated.
|
||||
|
||||
MVP defaulting:
|
||||
|
||||
- If `publish.html` is false, transform may be omitted.
|
||||
- If `publish.html` is true and `transform.markdown_to_html` is omitted or disabled, config validation must fail.
|
||||
|
||||
## Validation Policy
|
||||
|
||||
Pipeline-level validation is intentionally narrow in the MVP.
|
||||
|
||||
```yaml
|
||||
validation:
|
||||
on_digest_mismatch: fail
|
||||
```
|
||||
|
||||
Supported value:
|
||||
|
||||
- `fail`
|
||||
|
||||
Default:
|
||||
|
||||
```yaml
|
||||
on_digest_mismatch: fail
|
||||
```
|
||||
|
||||
Validation should happen before any destination writes. Warning-only digest mismatch handling is deferred and must be rejected if configured.
|
||||
|
||||
## Transfer Policy
|
||||
|
||||
Destination-level transfer policy controls behavior after inspecting `.distributor.json` at the destination bundle path.
|
||||
|
||||
```yaml
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
```
|
||||
|
||||
Supported fields:
|
||||
|
||||
- `on_destination_same`
|
||||
- `on_destination_older`
|
||||
- `on_destination_newer`
|
||||
- `on_conflict`
|
||||
|
||||
MVP supported values are intentionally limited by field:
|
||||
|
||||
- `on_destination_same`: `skip` or `fail`
|
||||
- `on_destination_older`: `replace` or `fail`
|
||||
- `on_destination_newer`: `skip` or `fail`
|
||||
- `on_conflict`: `fail`
|
||||
|
||||
Recommended MVP defaults:
|
||||
|
||||
```yaml
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
```
|
||||
|
||||
Safety rule:
|
||||
|
||||
- `replace` must never perform broad deletion against a destination root.
|
||||
- `replace` may only operate within a resolved destination bundle path and should delete only files recorded in existing `.distributor.json.outputs` plus `.distributor.json` where practical.
|
||||
- Unmanaged non-empty destination paths fail in the MVP. Force or unmanaged overwrite configuration is deferred.
|
||||
- Broader replacement values, including replacing newer destinations or conflicts, are deferred to a later explicit force-overwrite stage.
|
||||
|
||||
## Path Mapping
|
||||
|
||||
MVP path mapping is fixed:
|
||||
|
||||
```text
|
||||
destination bundle path = destination root + source relative bundle path
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
source root: /var/spool/reports
|
||||
source bundle: /var/spool/reports/weather/daily/brentwood/2026-05-30
|
||||
relative bundle path: weather/daily/brentwood/2026-05-30
|
||||
|
||||
destination root: /srv/www/reports
|
||||
destination bundle path: /srv/www/reports/weather/daily/brentwood/2026-05-30
|
||||
```
|
||||
|
||||
Future config may support explicit path mapping, but MVP should not.
|
||||
|
||||
## Dry Run Configuration and CLI Behavior
|
||||
|
||||
Dry-run should be a CLI flag rather than a persistent config setting.
|
||||
|
||||
```bash
|
||||
distributor run --config config.yml --dry-run
|
||||
```
|
||||
|
||||
Dry-run should report:
|
||||
|
||||
- pipeline ID;
|
||||
- source backend;
|
||||
- destination ID;
|
||||
- destination backend;
|
||||
- discovered bundle ID;
|
||||
- relative bundle path;
|
||||
- planned action;
|
||||
- reason;
|
||||
- transform outputs that would be generated;
|
||||
- files that would be written or deleted.
|
||||
|
||||
## Example: Weather Pipeline
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
- id: weather-daily
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/weather
|
||||
|
||||
validation:
|
||||
on_digest_mismatch: fail
|
||||
|
||||
destinations:
|
||||
- id: markdown-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: weather/archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
|
||||
- id: static-site
|
||||
backend: ssh
|
||||
uri: ssh://deploy@web.example.com:22
|
||||
path: /srv/www/weather
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
```
|
||||
|
||||
## Example: D&D Recap Pipeline
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
- id: dnd-session-recaps
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/dnd/session-recaps
|
||||
|
||||
destinations:
|
||||
- id: private-markdown-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: dnd/session-recaps
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
|
||||
- id: private-html-site
|
||||
backend: local
|
||||
path: /srv/www/private/dnd/session-recaps
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
```
|
||||
|
||||
## Future Notification Configuration
|
||||
|
||||
Notification should not be exposed as a functional MVP feature unless an adapter exists.
|
||||
|
||||
The internal pipeline may include a no-op notification stage. Future config may look like:
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
- id: weather-email
|
||||
backend: email
|
||||
after_destinations:
|
||||
- static-site
|
||||
subject: "Weather report published"
|
||||
```
|
||||
|
||||
Future notification policy should require:
|
||||
|
||||
- notification after successful relevant publication;
|
||||
- idempotency by source manifest id and digest;
|
||||
- no duplicate notification unless explicitly forced.
|
||||
|
||||
## Implementation Stages
|
||||
|
||||
1. Define config structs for pipelines, sources, destinations, validation, publish, transform, and transfer policies.
|
||||
2. Implement config loading and strict validation.
|
||||
3. Implement backend-specific config validation for local, SSH, and S3.
|
||||
4. Implement defaulting for validation and transfer policies.
|
||||
5. Require explicit transform configuration when `publish.html` is true.
|
||||
6. Add example config fixtures for local-to-local, local-to-SSH, local-to-S3, and fan-out scenarios.
|
||||
7. Connect config to backend registry and publish planner.
|
||||
8. Add `--pipeline` filtering for targeted runs.
|
||||
9. Add `--dry-run` output that reflects the resolved config and planned actions.
|
||||
|
||||
## Deferred Configuration
|
||||
|
||||
The following configuration ideas are intentionally outside the MVP:
|
||||
|
||||
- warning-only digest mismatch handling;
|
||||
- unmanaged destination overwrite flags until the explicit force-overwrite roadmap stage;
|
||||
- force replacement of destinations with different source ids until the explicit force-overwrite roadmap stage.
|
||||
@@ -1,398 +0,0 @@
|
||||
# Distributor Contracts Roadmap
|
||||
|
||||
This roadmap defines the contracts that `distributor` should implement before or alongside the MVP. The goal is to make bundle validation, destination state, digest verification, and safe replacement deterministic and testable before backend-specific publication behavior is layered on top.
|
||||
|
||||
## Purpose
|
||||
|
||||
`distributor` publishes manifested report bundles produced by other applications. Producer applications own domain-specific report generation. `distributor` owns validation, optional transformation, destination publication, and destination state.
|
||||
|
||||
The MVP contract has two durable files:
|
||||
|
||||
- `manifest.json`: source-owned bundle manifest produced by the upstream application.
|
||||
- `.distributor.json`: destination-owned publication state written by `distributor`.
|
||||
|
||||
`manifest.json` is not copied to the destination as destination state. Instead, `.distributor.json` records the normalized source manifest, generated output metadata, and distributor-owned publication metadata.
|
||||
|
||||
All manifest and state timestamps should be serialized as RFC3339. Internal comparison should use parsed timestamp values, and distributor-written timestamps should be normalized to RFC3339 UTC.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Source root**: Configured root path for a pipeline source.
|
||||
- **Bundle root**: Directory beneath the source root that contains `manifest.json`.
|
||||
- **Relative bundle path**: Bundle root path relative to the source root.
|
||||
- **Destination root**: Configured root path or prefix for a destination.
|
||||
- **Destination bundle path**: Destination root plus the relative bundle path, unless a future mapping option overrides that behavior.
|
||||
- **Source artifact**: File listed in the source `manifest.json`.
|
||||
- **Generated artifact**: File created by `distributor`, such as an HTML file derived from Markdown.
|
||||
- **Destination state**: `.distributor.json` at the destination bundle path.
|
||||
|
||||
## Source Bundle Contract
|
||||
|
||||
A source bundle is a directory containing a `manifest.json` file. For MVP, the manifest schema is intentionally minimal.
|
||||
|
||||
### Required `manifest.json` fields
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"created": "2026-05-30T11:10:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "report.md",
|
||||
"sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"size": 12345
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Required top-level fields:
|
||||
|
||||
- `schema_version`: Source manifest schema version. MVP value: `1`.
|
||||
- `id`: Stable bundle identifier. Required, non-empty string.
|
||||
- `digest`: Bundle digest. Required, lowercase `sha256:<64 hex>` string.
|
||||
- `created`: Bundle creation timestamp. Required RFC3339 timestamp. UTC is preferred; explicit offsets are allowed.
|
||||
- `files`: Non-empty array of file objects.
|
||||
|
||||
Required file fields:
|
||||
|
||||
- `path`: Relative path from bundle root to source artifact.
|
||||
- `sha256`: Per-file digest as lowercase `sha256:<64 hex>`.
|
||||
- `size`: File size in bytes.
|
||||
|
||||
No other source manifest fields are required for the MVP. Additional fields may be ignored unless later documented. Destination state records the normalized source manifest model, not raw unknown manifest fields.
|
||||
|
||||
## Source Path Safety Rules
|
||||
|
||||
For every `files[].path`:
|
||||
|
||||
- Path must be relative.
|
||||
- Path must not be empty.
|
||||
- Path must not contain `..` segments.
|
||||
- Path must not resolve outside the bundle root.
|
||||
- Path must use slash-separated logical paths in the manifest.
|
||||
- Absolute paths are invalid.
|
||||
- Symlinks should be rejected for MVP unless a later policy deliberately supports them.
|
||||
- `manifest.json` itself should not be listed as a source artifact.
|
||||
- `.distributor.json` should not be listed as a source artifact.
|
||||
- Duplicate logical file paths are invalid after path normalization.
|
||||
|
||||
The implementation should validate paths before reading file contents.
|
||||
|
||||
## Digest Contract
|
||||
|
||||
The MVP validates both per-file digests and the bundle digest.
|
||||
|
||||
### Per-file digest
|
||||
|
||||
For each file listed in `files`, compute:
|
||||
|
||||
```text
|
||||
sha256(file bytes)
|
||||
```
|
||||
|
||||
The computed digest must match `files[].sha256`.
|
||||
|
||||
The actual file size must match `files[].size`.
|
||||
|
||||
### Bundle digest
|
||||
|
||||
The bundle digest is computed from the listed file records in the listed order. The canonical algorithm is:
|
||||
|
||||
1. For each file listed in `files`, in order:
|
||||
- validate the file path;
|
||||
- compute the file SHA256;
|
||||
- determine the file size.
|
||||
2. Construct a canonical JSON array containing only:
|
||||
- `path`;
|
||||
- `sha256`;
|
||||
- `size`.
|
||||
3. Preserve the source manifest's file order.
|
||||
4. Encode the array deterministically with:
|
||||
- object fields in exactly this order: `path`, `sha256`, `size`;
|
||||
- no extra spaces;
|
||||
- no trailing newline;
|
||||
- lowercase `sha256:<64 hex>` digest strings.
|
||||
5. Compute `sha256(canonical JSON bytes)`.
|
||||
6. Compare the result to top-level `digest`.
|
||||
|
||||
Conceptual canonical payload:
|
||||
|
||||
```json
|
||||
[
|
||||
{"path":"report.md","sha256":"sha256:...","size":12345},
|
||||
{"path":"summary.txt","sha256":"sha256:...","size":234}
|
||||
]
|
||||
```
|
||||
|
||||
This avoids ambiguous concatenation of file bytes while keeping the manifest small.
|
||||
|
||||
Implementation fixtures should include at least one reference manifest and canonical payload with known per-file and bundle digests.
|
||||
|
||||
## Digest Mismatch Behavior
|
||||
|
||||
Digest validation is always fatal in the MVP. A digest mismatch must fail the affected bundle, pipeline, and run before any destination writes occur.
|
||||
|
||||
Warning-only digest behavior is intentionally deferred until a later roadmap accepts transitional ingestion semantics.
|
||||
|
||||
## Bundle Discovery Contract
|
||||
|
||||
A source path may contain one bundle or a tree of bundles. Discovery should scan beneath the configured source root for `manifest.json` files.
|
||||
|
||||
For each discovered manifest:
|
||||
|
||||
- Bundle root is the directory containing `manifest.json`.
|
||||
- Relative bundle path is computed relative to source root.
|
||||
- Destination bundle path is destination root plus relative bundle path, unless a future mapping option overrides it.
|
||||
|
||||
If nested manifests are found, the MVP should fail with a clear error unless a later policy defines nested-bundle semantics.
|
||||
|
||||
## Destination State Contract
|
||||
|
||||
Destinations are managed by `.distributor.json`, not by copying `manifest.json`.
|
||||
|
||||
A destination bundle path is considered distributor-managed only when it contains a valid `.distributor.json` written by `distributor`. Force or unmanaged-overwrite behavior is not part of the MVP.
|
||||
|
||||
### Required `.distributor.json` shape
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"distributor_version": "0.1.0",
|
||||
"pipeline_id": "weather-daily",
|
||||
"destination_id": "static-site",
|
||||
"published_at": "2026-05-30T11:12:00Z",
|
||||
"source": {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"created": "2026-05-30T11:10:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "report.md",
|
||||
"sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"size": 12345
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"path": "report.html",
|
||||
"kind": "generated",
|
||||
"source_path": "report.md",
|
||||
"transform": "markdown_to_html",
|
||||
"sha256": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
|
||||
"size": 23456
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `schema_version`: Destination state schema version. MVP value: `1`.
|
||||
- `distributor_version`: Optional diagnostic distributor version. It must not affect source comparison.
|
||||
- `pipeline_id`: Pipeline that produced the destination publication.
|
||||
- `destination_id`: Destination within the pipeline.
|
||||
- `published_at`: RFC3339 timestamp.
|
||||
- `source.manifest`: Normalized source manifest model used for this publication.
|
||||
- `outputs`: Array of files written by `distributor` for this destination.
|
||||
|
||||
Required output fields:
|
||||
|
||||
- `path`: Destination-relative output path within the destination bundle path.
|
||||
- `kind`: `source` or `generated`.
|
||||
- `source_path`: Source artifact path that produced this output. For copied source files, this should equal `path` unless renamed by a future feature.
|
||||
- `transform`: Transform identifier for generated files. Empty or omitted may be allowed for copied source files.
|
||||
- `sha256`: Output file digest as lowercase `sha256:<64 hex>`.
|
||||
- `size`: Output file size in bytes.
|
||||
|
||||
## Destination Comparison Rules
|
||||
|
||||
Destination comparison uses `.distributor.json`, not destination `manifest.json`.
|
||||
|
||||
For a source bundle and destination bundle path:
|
||||
|
||||
### No `.distributor.json`
|
||||
|
||||
If no `.distributor.json` exists and the destination path is empty:
|
||||
|
||||
- Publish normally.
|
||||
|
||||
If no `.distributor.json` exists and the destination path is non-empty:
|
||||
|
||||
- Fail as unmanaged content.
|
||||
|
||||
Local destination paths are empty when the destination bundle directory does not exist or exists with no entries. S3-compatible destination prefixes are empty when no objects exist below the destination bundle prefix, ignoring objects outside that exact prefix.
|
||||
|
||||
### Pipeline or destination mismatch
|
||||
|
||||
If `.distributor.json` exists but its `pipeline_id` or `destination_id` differs from the current pipeline or destination config:
|
||||
|
||||
- Fail as a conflict.
|
||||
|
||||
### Same source manifest
|
||||
|
||||
If `.distributor.json` exists and `source.manifest` exactly matches the current normalized source manifest:
|
||||
|
||||
- Skip as already published.
|
||||
|
||||
### Same source id, destination older
|
||||
|
||||
If `.distributor.json` exists, `source.manifest.id` matches the source manifest `id`, and destination `source.manifest.created` is older than the source `created`:
|
||||
|
||||
- Replace destination contents, subject to replacement safety rules.
|
||||
|
||||
### Same source id, destination newer
|
||||
|
||||
If `.distributor.json` exists, `source.manifest.id` matches the source manifest `id`, and destination `source.manifest.created` is newer than the source `created`:
|
||||
|
||||
- Skip and log that destination is newer than source.
|
||||
|
||||
### Same source id and created, different digest
|
||||
|
||||
If `.distributor.json` exists, `source.manifest.id` and `created` match but `digest` differs:
|
||||
|
||||
- Fail as a conflict.
|
||||
|
||||
### Different source id
|
||||
|
||||
If `.distributor.json` exists and `source.manifest.id` differs from the source manifest `id`:
|
||||
|
||||
- Fail as a conflict.
|
||||
|
||||
## Replacement Safety Rules
|
||||
|
||||
Replacement is destructive and must be narrow.
|
||||
|
||||
`distributor` must never perform broad deletion against a configured destination root.
|
||||
|
||||
Replacement may occur only at a resolved destination bundle path when:
|
||||
|
||||
- a valid `.distributor.json` exists at that destination bundle path; and
|
||||
- the state identifies the path as distributor-managed; and
|
||||
- the replacement decision follows the destination comparison rules.
|
||||
|
||||
For MVP, replacement should delete only known managed outputs where practical:
|
||||
|
||||
- files listed in existing `.distributor.json.outputs`;
|
||||
- existing `.distributor.json`;
|
||||
- empty directories created by those files, where applicable for filesystem-like backends.
|
||||
|
||||
For S3, replacement should delete only objects under the destination bundle prefix that are listed in `.distributor.json.outputs` plus `.distributor.json`, unless a later managed-prefix deletion policy is explicitly implemented.
|
||||
|
||||
Before any write, planned destination output paths must be checked for collisions. For example, if `report.md` is copied as a source artifact and Markdown transformation would also generate `report.html`, but `report.html` is already a source artifact or another generated output, planning must fail before writing.
|
||||
|
||||
## Transform Output Contract
|
||||
|
||||
Source files are canonical. Generated files are derived publication artifacts.
|
||||
|
||||
For MVP, the only supported transform is Markdown to HTML.
|
||||
|
||||
Recommended MVP behavior:
|
||||
|
||||
- Transform is configured per destination.
|
||||
- Markdown source files are files listed in `manifest.json` with `.md` extension.
|
||||
- Generated HTML files are sidecars by default.
|
||||
- `report.md` generates `report.html`.
|
||||
- Source bundles are never mutated.
|
||||
- Generated outputs are recorded in `.distributor.json.outputs`.
|
||||
- Raw HTML embedded in Markdown is escaped or disabled by default for the MVP to keep generated output deterministic and conservative.
|
||||
|
||||
Future versions may add templates, `index.html`, CSS assets, email-safe HTML, and per-file transform selection.
|
||||
|
||||
## Destination Output Contract
|
||||
|
||||
Each destination chooses which categories of files it receives.
|
||||
|
||||
MVP categories:
|
||||
|
||||
- `source`: copied source artifacts listed in `manifest.json`.
|
||||
- `html`: generated HTML derived from Markdown source artifacts.
|
||||
|
||||
Examples:
|
||||
|
||||
- Markdown archive: `source: true`, `html: false`.
|
||||
- Static HTML site: `source: false`, `html: true`.
|
||||
- Full mirror: `source: true`, `html: true`.
|
||||
|
||||
Every file written to the destination must be represented in `.distributor.json.outputs`.
|
||||
|
||||
## Atomicity and Partial Failure
|
||||
|
||||
The MVP should prefer staging and promotion where backend semantics permit it.
|
||||
|
||||
Minimum behavior:
|
||||
|
||||
- Validate source before writing destination files.
|
||||
- Do not write `.distributor.json` until all configured destination outputs are successfully written.
|
||||
- If publication fails before `.distributor.json` is written, the destination must not be treated as successfully published on a later run.
|
||||
- Local publication must use staging or equivalent cleanup behavior so a failed write does not leave a confusing unmanaged destination bundle path.
|
||||
- Later cleanup may remove orphaned files, but MVP correctness should rely on `.distributor.json` as the success marker.
|
||||
|
||||
## Example Source Bundle
|
||||
|
||||
```text
|
||||
weather/daily/brentwood/2026-05-30/
|
||||
manifest.json
|
||||
report.md
|
||||
summary.txt
|
||||
```
|
||||
|
||||
Example manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"created": "2026-05-30T11:10:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "report.md",
|
||||
"sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"size": 12345
|
||||
},
|
||||
{
|
||||
"path": "summary.txt",
|
||||
"sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
"size": 234
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Example Destination Bundle: HTML Only
|
||||
|
||||
```text
|
||||
weather/daily/brentwood/2026-05-30/
|
||||
report.html
|
||||
.distributor.json
|
||||
```
|
||||
|
||||
## Example Destination Bundle: Source Archive
|
||||
|
||||
```text
|
||||
weather/daily/brentwood/2026-05-30/
|
||||
report.md
|
||||
summary.txt
|
||||
.distributor.json
|
||||
```
|
||||
|
||||
## Implementation Stages
|
||||
|
||||
1. Define Go structs for source manifest and destination state.
|
||||
2. Implement source path validation.
|
||||
3. Implement per-file SHA256 and size validation.
|
||||
4. Implement canonical bundle digest validation.
|
||||
5. Implement source bundle discovery beneath a source root.
|
||||
6. Implement `.distributor.json` parsing and validation.
|
||||
7. Implement destination comparison rules.
|
||||
8. Implement replacement safety checks.
|
||||
9. Add fixture bundles for valid, invalid, duplicate path, older, newer, same, and conflict scenarios.
|
||||
10. Add reference canonical digest fixtures.
|
||||
11. Use the contract layer from the publish pipeline and backend adapters.
|
||||
@@ -1,863 +1,62 @@
|
||||
# Distributor Implementation Roadmap
|
||||
# Implementation Roadmap
|
||||
|
||||
This roadmap defines a staged implementation plan for the `distributor` MVP. Each stage is intended to map cleanly to one Codex implementation prompt.
|
||||
This roadmap records current implementation status and deferred work for
|
||||
`distributor`. Implemented behavior is documented in the user, operator, and
|
||||
internal documentation listed below.
|
||||
|
||||
The roadmap assumes the project includes these planning documents before implementation begins:
|
||||
Canonical current-behavior docs:
|
||||
|
||||
- `docs/policy/architecture.md`
|
||||
- `docs/policy/documentation.md`
|
||||
- `docs/roadmap/packages.md`
|
||||
- `docs/roadmap/contracts.md`
|
||||
- `docs/roadmap/config.md`
|
||||
- `docs/roadmap/storage.md`
|
||||
- `README.md`
|
||||
- `docs/cli.md`
|
||||
- `docs/config.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/internal/`
|
||||
- `docs/integrations/markdown.md`
|
||||
- `docs/policy/`
|
||||
- `examples/`
|
||||
|
||||
The MVP goal is a domain-agnostic bundle distributor that discovers source bundles, validates `manifest.json`, optionally transforms Markdown to HTML, publishes selected outputs to one or more destinations, and records destination state in `.distributor.json`.
|
||||
Future, planned, or aspirational behavior belongs under `docs/roadmap/` until
|
||||
it is implemented.
|
||||
|
||||
## Global Implementation Rules
|
||||
## Current State
|
||||
|
||||
All stages should preserve these invariants:
|
||||
`distributor` is ready for routine use against producer pipelines using the
|
||||
implemented local, SSH/SFTP, and S3-compatible backends.
|
||||
|
||||
- Producer applications own source bundle creation.
|
||||
- `distributor` owns validation, transformation, publication, destination state, and future notification hooks.
|
||||
- Source bundle state is defined by `manifest.json`.
|
||||
- Destination publication state is defined by `.distributor.json`.
|
||||
- `manifest.json` is not copied to the destination as destination state.
|
||||
- Pipelines have exactly one source and one or more destinations.
|
||||
- Transform and publish policy are destination-specific.
|
||||
- Destructive replacement is allowed only inside a managed destination bundle path. Unsafe force or unmanaged overwrite behavior is deferred.
|
||||
- Dry-run behavior should be implemented before broad remote write behavior.
|
||||
- Config, bundle, state, publish planning, storage adapters, transforms, and CLI wiring should remain separate packages.
|
||||
## Active Roadmap
|
||||
|
||||
Unless a stage explicitly says otherwise, each implementation prompt should:
|
||||
There are no active implementation items in this roadmap.
|
||||
|
||||
1. read the project policy and roadmap documents;
|
||||
2. implement only the current stage;
|
||||
3. add or update tests for the current stage;
|
||||
4. run the relevant test suite;
|
||||
5. update documentation only when the implemented behavior now exists;
|
||||
6. avoid implementing future roadmap stages early.
|
||||
Before implementing new product behavior, add a focused roadmap entry when the
|
||||
work changes storage semantics, config, CLI behavior, state schema, transform
|
||||
behavior, notification behavior, operational safety, or user-visible workflows.
|
||||
Keep those entries out of current-behavior docs until the behavior exists.
|
||||
|
||||
## Stage 1: Project Skeleton, CLI Shell, and Baseline Tooling
|
||||
## Deferred Work
|
||||
|
||||
### Goal
|
||||
These items are not implemented and should stay out of current-behavior docs
|
||||
until a roadmap entry is selected and implemented:
|
||||
|
||||
Create the initial Go application structure and a minimal executable `distributor` command with no business behavior beyond version/help output and placeholder commands.
|
||||
|
||||
### Scope
|
||||
|
||||
Implement the accepted package skeleton from `docs/roadmap/packages.md` at the level needed for compilation.
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
cmd/distributor/main.go
|
||||
internal/cli/
|
||||
internal/app/
|
||||
internal/config/
|
||||
internal/logging/
|
||||
```
|
||||
|
||||
Initial CLI commands:
|
||||
|
||||
- `distributor --help`
|
||||
- `distributor version`
|
||||
- `distributor run`
|
||||
- `distributor validate`
|
||||
- `distributor inspect`
|
||||
|
||||
At this stage, `run`, `validate`, and `inspect` may return clear “not implemented” errors, but the command structure should be present.
|
||||
|
||||
### Notes
|
||||
|
||||
Prefer a small CLI dependency only if the project already standardizes on one. Otherwise, the standard library is acceptable for the first pass.
|
||||
|
||||
Add a version variable that can later be set at build time.
|
||||
|
||||
### Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- command construction if testable;
|
||||
- version string behavior if exposed through a package;
|
||||
- basic package compilation.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- `go test ./...` passes.
|
||||
- `go run ./cmd/distributor --help` works.
|
||||
- `go run ./cmd/distributor version` works.
|
||||
- Placeholder operational commands fail clearly and intentionally.
|
||||
|
||||
## Stage 2: Config Schema, Loading, Defaults, and Validation
|
||||
|
||||
### Goal
|
||||
|
||||
Implement the MVP `config.yml` schema described in `docs/roadmap/config.md`.
|
||||
|
||||
### Scope
|
||||
|
||||
Create config structs for:
|
||||
|
||||
- top-level config;
|
||||
- pipelines;
|
||||
- source backend config;
|
||||
- destination backend config;
|
||||
- validation policy;
|
||||
- publish policy;
|
||||
- transform policy;
|
||||
- transfer/replacement policy;
|
||||
- backend-specific local, SSH, and S3 fields.
|
||||
|
||||
Support loading YAML from a file path.
|
||||
|
||||
Implement validation for:
|
||||
|
||||
- required top-level `pipelines`;
|
||||
- unique pipeline ids;
|
||||
- required pipeline `id`, `source`, and non-empty `destinations`;
|
||||
- unique destination ids within a pipeline;
|
||||
- supported backend names: `local`, `ssh`, `s3`;
|
||||
- required backend fields;
|
||||
- supported validation action: `fail`;
|
||||
- supported transfer actions;
|
||||
- valid `publish` policy;
|
||||
- valid Markdown-to-HTML transform config.
|
||||
|
||||
Default behavior should match `docs/roadmap/config.md`.
|
||||
|
||||
### CLI Integration
|
||||
|
||||
Add `--config` to `run`.
|
||||
|
||||
For this stage, `distributor run --config config.yml --dry-run` may only load and validate config, then print a concise summary of configured pipelines and destinations.
|
||||
|
||||
### Tests
|
||||
|
||||
Add unit tests for:
|
||||
|
||||
- valid minimal local-to-local config;
|
||||
- valid fan-out config;
|
||||
- valid local, SSH, and S3 backend configs;
|
||||
- duplicate pipeline ids;
|
||||
- duplicate destination ids;
|
||||
- missing required fields;
|
||||
- unsupported backend;
|
||||
- invalid transfer action;
|
||||
- invalid validation action, including `warn`.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Config load/default/validate behavior is implemented and tested.
|
||||
- `distributor run --config <file> --dry-run` validates config and prints a summary.
|
||||
- No bundle discovery or publication occurs yet.
|
||||
|
||||
## Stage 3: Storage Abstraction, Local Backend, and Fake Backend
|
||||
|
||||
### Goal
|
||||
|
||||
Introduce the storage backend abstraction before bundle validation so source discovery, validation, and publication are backend-agnostic from the start.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/storage/backend.go
|
||||
internal/storage/registry.go
|
||||
internal/storage/path.go
|
||||
internal/storage/errors.go
|
||||
internal/adapters/local/backend.go
|
||||
internal/storage/fake/
|
||||
```
|
||||
|
||||
Implement the storage contract in `docs/roadmap/storage.md`, including backend-rooted logical paths, hybrid byte/stream IO, metadata, traversal, typed errors, managed deletion, and efficient destination emptiness helper behavior.
|
||||
|
||||
The fake backend should exist for unit tests of config, bundle, state, and publish logic without real local, SSH, or S3 IO.
|
||||
|
||||
### Safety Requirements
|
||||
|
||||
The local backend must:
|
||||
|
||||
- clean and join paths safely;
|
||||
- reject path traversal;
|
||||
- reject unsafe destructive deletion requests;
|
||||
- avoid following symlinks for source bundle files unless explicitly supported;
|
||||
- avoid deleting configured roots;
|
||||
- classify destination bundle emptiness deterministically.
|
||||
|
||||
### Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- path validation;
|
||||
- backend byte and stream read/write behavior;
|
||||
- `Stat`, `Walk`, and materialized deterministic traversal helper behavior;
|
||||
- traversal rejection;
|
||||
- symlink entry reporting and source-read rejection;
|
||||
- staged write behavior where testable;
|
||||
- typed storage errors and helper predicates;
|
||||
- managed deletion guard behavior;
|
||||
- early-stop destination emptiness helper behavior;
|
||||
- fake backend parity for core package tests.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Local backend implements the storage interface.
|
||||
- Fake backend can support bundle and publish tests without external services.
|
||||
- `go test ./...` passes.
|
||||
- No SSH or S3 implementation exists yet.
|
||||
|
||||
## Stage 4: Source Bundle Manifest, Digest, Validation, and Discovery
|
||||
|
||||
### Goal
|
||||
|
||||
Implement the source bundle contract from `docs/roadmap/contracts.md` through the storage abstraction.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/bundle/manifest.go
|
||||
internal/bundle/digest.go
|
||||
internal/bundle/validate.go
|
||||
internal/bundle/discover.go
|
||||
```
|
||||
|
||||
Implement:
|
||||
|
||||
- parsing `manifest.json`;
|
||||
- strict required field validation, including `schema_version: 1`;
|
||||
- RFC3339 `created` parsing;
|
||||
- lowercase `sha256:<64 hex>` digest validation;
|
||||
- source file path safety checks;
|
||||
- duplicate logical file path rejection;
|
||||
- per-file SHA256 validation;
|
||||
- per-file size validation;
|
||||
- bundle digest validation using the canonical ordered file-record algorithm;
|
||||
- deterministic storage-backed bundle discovery under a source root;
|
||||
- nested manifest detection and failure.
|
||||
|
||||
Discovery and validation should use `internal/storage` rather than direct `os` APIs. The local CLI path should be adapted to the local backend.
|
||||
|
||||
### CLI Integration
|
||||
|
||||
Implement:
|
||||
|
||||
```text
|
||||
distributor validate <path>
|
||||
distributor inspect <path>
|
||||
```
|
||||
|
||||
For local paths:
|
||||
|
||||
- `validate` should validate either a single bundle directory or a tree containing bundles.
|
||||
- `inspect` should print a concise normalized summary of discovered bundle ids, relative paths, created timestamps, digest values, and files.
|
||||
|
||||
### Tests
|
||||
|
||||
Add fixture bundles under a testdata directory.
|
||||
|
||||
Test:
|
||||
|
||||
- valid bundle;
|
||||
- invalid JSON;
|
||||
- missing required fields;
|
||||
- invalid schema version;
|
||||
- invalid timestamp;
|
||||
- invalid digest format;
|
||||
- unsafe file paths;
|
||||
- duplicate normalized file paths;
|
||||
- missing files;
|
||||
- size mismatch;
|
||||
- per-file digest mismatch;
|
||||
- bundle digest mismatch;
|
||||
- canonical bundle digest reference fixture;
|
||||
- multiple discovered bundles in deterministic order;
|
||||
- nested manifests fail.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Storage-backed bundle validation is deterministic and well-tested.
|
||||
- `distributor validate <path>` works for local bundle fixtures.
|
||||
- `distributor inspect <path>` works for local bundle fixtures.
|
||||
- No destination publication occurs yet.
|
||||
|
||||
## Stage 5: Destination State Contract and Comparison Logic
|
||||
|
||||
### Goal
|
||||
|
||||
Implement `.distributor.json` parsing, validation, and source-to-destination comparison.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/state/distributor.go
|
||||
internal/state/compare.go
|
||||
internal/state/validate.go
|
||||
```
|
||||
|
||||
Implement the destination state schema from `docs/roadmap/contracts.md`, including:
|
||||
|
||||
- `schema_version`;
|
||||
- optional `distributor_version`;
|
||||
- `pipeline_id`;
|
||||
- `destination_id`;
|
||||
- `published_at`;
|
||||
- embedded normalized source manifest;
|
||||
- outputs array;
|
||||
- output file metadata.
|
||||
|
||||
Implement comparison outcomes:
|
||||
|
||||
- destination absent;
|
||||
- destination unmanaged/non-empty;
|
||||
- destination state pipeline or destination id mismatch;
|
||||
- same source manifest;
|
||||
- same source id, destination older;
|
||||
- same source id, destination newer;
|
||||
- same source id and same created but different digest;
|
||||
- different source id;
|
||||
- invalid destination state.
|
||||
|
||||
### Tests
|
||||
|
||||
Add unit tests for every comparison outcome.
|
||||
|
||||
Test validation for:
|
||||
|
||||
- valid state;
|
||||
- missing fields;
|
||||
- invalid schema version;
|
||||
- invalid embedded source manifest;
|
||||
- invalid output metadata;
|
||||
- malformed published timestamp.
|
||||
|
||||
Timestamps should parse RFC3339 input and distributor-written timestamps should normalize to RFC3339 UTC.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Destination state can be parsed and validated independently.
|
||||
- Source manifest to destination state comparison is deterministic and fully tested.
|
||||
- No publication execution occurs yet.
|
||||
|
||||
## Stage 6: Publish Planning, Dry-Run, and Local-to-Local Publication Without Transform
|
||||
|
||||
### Goal
|
||||
|
||||
Implement the core publish planner and execute local-to-local publication for source files only.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/publish/plan.go
|
||||
internal/publish/reconcile.go
|
||||
internal/publish/safety.go
|
||||
internal/publish/output.go
|
||||
internal/publish/execute.go
|
||||
```
|
||||
|
||||
Implement planning for one source bundle to one destination based on:
|
||||
|
||||
- source manifest;
|
||||
- destination config;
|
||||
- publish policy;
|
||||
- transfer policy;
|
||||
- existing `.distributor.json`;
|
||||
- destination path state.
|
||||
|
||||
Actions should include:
|
||||
|
||||
- publish new;
|
||||
- replace older destination;
|
||||
- skip same;
|
||||
- skip destination newer;
|
||||
- fail conflict;
|
||||
- fail unmanaged destination.
|
||||
|
||||
Implement local-to-local execution for `publish.source: true` and `publish.html: false`.
|
||||
|
||||
Execution should:
|
||||
|
||||
- copy listed source files selected by publish policy;
|
||||
- write `.distributor.json` with copied source output metadata;
|
||||
- avoid copying source `manifest.json` as destination state;
|
||||
- preserve relative bundle paths from source root beneath destination root;
|
||||
- detect destination output collisions before writing;
|
||||
- use staging or equivalent cleanup behavior for local writes;
|
||||
- support fan-out to multiple local destinations;
|
||||
- support dry-run without writes.
|
||||
|
||||
### CLI Integration
|
||||
|
||||
`distributor run --config <file>` should now execute local-to-local pipelines when configured.
|
||||
|
||||
`--dry-run` should print the planned action for each discovered bundle and destination.
|
||||
|
||||
### Tests
|
||||
|
||||
Add integration-style tests using temp directories for:
|
||||
|
||||
- new local publication;
|
||||
- no-op when destination state matches;
|
||||
- replacement when destination state is older;
|
||||
- skip when destination state is newer;
|
||||
- fail on conflict;
|
||||
- fail on unmanaged non-empty destination;
|
||||
- fail on output path collision;
|
||||
- fan-out from one source to two local destinations;
|
||||
- failed local write does not leave a destination that appears unmanaged on retry;
|
||||
- dry-run performs no writes;
|
||||
- `.distributor.json` is written correctly.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Local-to-local source-file publication works end to end.
|
||||
- Dry-run produces meaningful planned actions.
|
||||
- Destination state is authoritative.
|
||||
- No Markdown-to-HTML transform exists yet.
|
||||
|
||||
## Stage 7: Markdown-to-HTML Transform and Destination-Specific Publish Policy
|
||||
|
||||
### Goal
|
||||
|
||||
Add MVP Markdown-to-HTML transformation and destination-specific source/html output selection.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/transform/transform.go
|
||||
internal/transform/registry.go
|
||||
internal/transform/plan.go
|
||||
internal/transform/markdown/markdown.go
|
||||
internal/transform/markdown/template.go
|
||||
```
|
||||
|
||||
Implement only:
|
||||
|
||||
```yaml
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
```
|
||||
|
||||
MVP sidecar behavior:
|
||||
|
||||
- for each listed source artifact ending in `.md`, generate a same-directory `.html` sidecar;
|
||||
- preserve the original Markdown file unchanged;
|
||||
- do not generate HTML for non-Markdown files;
|
||||
- escape or disable raw HTML embedded in Markdown;
|
||||
- fail before writing when generated output paths collide with copied source outputs or other generated outputs;
|
||||
- record generated output metadata in `.distributor.json`;
|
||||
- if `publish.source: false`, do not publish source files;
|
||||
- if `publish.html: true`, publish generated HTML files;
|
||||
- if `publish.html: true` but transform is disabled or no Markdown files exist, fail with a clear error unless config later defines another behavior.
|
||||
|
||||
Use a well-maintained Markdown renderer. Keep HTML templating minimal and deterministic.
|
||||
|
||||
### Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- Markdown sidecar generation;
|
||||
- source-only destination;
|
||||
- HTML-only destination;
|
||||
- source-plus-HTML destination;
|
||||
- no mutation of source bundle;
|
||||
- generated output metadata in `.distributor.json`;
|
||||
- failure when HTML publication is requested without transform support;
|
||||
- failure when generated HTML collides with a source artifact path;
|
||||
- raw HTML in Markdown is escaped or disabled consistently;
|
||||
- deterministic output for a fixture Markdown file.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Local-to-local publication supports source-only, HTML-only, and source-plus-HTML destinations.
|
||||
- Generated outputs are recorded in destination state.
|
||||
- Dry-run reports transform outputs that would be generated.
|
||||
|
||||
## Stage 8: No-Op Notification Stage, Pipeline Polish, and Local MVP Checkpoint
|
||||
|
||||
### Goal
|
||||
|
||||
Add the internal no-op notification stage and polish orchestration around per-destination outcomes.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/notify/notify.go
|
||||
internal/notify/noop.go
|
||||
```
|
||||
|
||||
Integrate a no-op notifier after actual successful publication or replacement. Skipped destinations should not invoke the notifier in the MVP.
|
||||
|
||||
Clarify orchestration behavior when one destination fails. For MVP, fan-out should be deterministic and sequential. Continue planning and reporting later destinations where safe, but return non-zero if any destination fails.
|
||||
|
||||
Improve run summary output:
|
||||
|
||||
- pipeline id;
|
||||
- source backend;
|
||||
- discovered bundle count;
|
||||
- destination ids;
|
||||
- action per bundle/destination;
|
||||
- final status.
|
||||
|
||||
### Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- notifier is invoked at the expected orchestration point where testable;
|
||||
- pipeline failure when a destination fails;
|
||||
- run summary contains meaningful status information;
|
||||
- dry-run does not invoke write-side effects.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- The pipeline shape includes notification as an internal no-op stage.
|
||||
- Run output is useful for unattended operation logs.
|
||||
- Local MVP behavior remains passing and is ready for one real local producer pipeline.
|
||||
|
||||
Stages 1 through 8 define the local MVP checkpoint. Later stages extend the local MVP with remote backends, cross-backend hardening, user-facing documentation sync, and release readiness.
|
||||
|
||||
## Stage 9: Native SSH/SFTP Backend Roadmap Extension
|
||||
|
||||
### Goal
|
||||
|
||||
Implement SSH/SFTP storage backend support for sources and destinations.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/adapters/ssh/backend.go
|
||||
internal/adapters/ssh/config.go
|
||||
```
|
||||
|
||||
Implement the storage backend interface over native SSH/SFTP.
|
||||
|
||||
Required config:
|
||||
|
||||
```yaml
|
||||
backend: ssh
|
||||
uri: ssh://user@example.com:22
|
||||
path: /remote/root
|
||||
```
|
||||
|
||||
Authentication expectations:
|
||||
|
||||
- prefer SSH agent by default;
|
||||
- use known_hosts validation by default where practical;
|
||||
- do not require passwords in YAML;
|
||||
- optional key-file support may be implemented if straightforward, but should not distract from agent-based auth.
|
||||
|
||||
Support SSH/SFTP backend as both source and destination:
|
||||
|
||||
- local -> ssh;
|
||||
- ssh -> local;
|
||||
- ssh -> ssh where feasible through staging or streaming.
|
||||
|
||||
### Safety Requirements
|
||||
|
||||
The SSH backend must enforce the same logical path safety rules as the local backend.
|
||||
|
||||
Deletion must remain limited to managed destination bundle paths guarded by valid `.distributor.json`.
|
||||
|
||||
### Tests
|
||||
|
||||
Unit-test path handling and config validation.
|
||||
|
||||
If practical, add integration tests that can be skipped unless an SSH test endpoint is configured through environment variables. Do not require a live SSH server for normal `go test ./...`.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- SSH/SFTP backend compiles and satisfies the storage interface.
|
||||
- Backend config validation is tested.
|
||||
- Normal tests do not depend on a live SSH server.
|
||||
- At least local-to-SSH and SSH-to-local flows are documented or manually testable.
|
||||
|
||||
## Stage 10: S3-Compatible Backend Roadmap Extension
|
||||
|
||||
### Goal
|
||||
|
||||
Implement S3-compatible backend support for sources and destinations.
|
||||
|
||||
### Scope
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
internal/adapters/s3/backend.go
|
||||
internal/adapters/s3/config.go
|
||||
```
|
||||
|
||||
Required config should align with `docs/roadmap/config.md`:
|
||||
|
||||
```yaml
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: some/prefix
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
Implement storage operations over S3 object keys through the common storage contract:
|
||||
|
||||
- `ReadFile` and `OpenReader`;
|
||||
- `WriteFile` and `WriteFrom`;
|
||||
- `Stat`;
|
||||
- `Walk` using object-list pagination where available;
|
||||
- `HasAny` with early stop for destination emptiness checks;
|
||||
- `DeleteManagedBundle`;
|
||||
- read/write `.distributor.json` through normal storage operations.
|
||||
|
||||
Set reasonable content types where available:
|
||||
|
||||
- `.md`: `text/markdown; charset=utf-8`;
|
||||
- `.html`: `text/html; charset=utf-8`;
|
||||
- `.json`: `application/json`;
|
||||
- `.txt`: `text/plain; charset=utf-8`.
|
||||
|
||||
Support S3 backend as both source and destination.
|
||||
|
||||
### Safety Requirements
|
||||
|
||||
Treat S3 prefixes as object trees. Do not assume real directories exist.
|
||||
|
||||
Deletion must be limited to destination bundle prefixes that are confirmed managed by `.distributor.json`.
|
||||
|
||||
### Tests
|
||||
|
||||
Add unit tests for:
|
||||
|
||||
- config validation;
|
||||
- key/prefix normalization;
|
||||
- content type selection;
|
||||
- path traversal rejection;
|
||||
- publish planning with S3 destination state fixtures.
|
||||
|
||||
If practical, add integration tests gated by environment variables or a local S3-compatible test service. Normal `go test ./...` must not require live S3 credentials.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- S3 backend compiles and satisfies the storage interface.
|
||||
- S3 source and destination flows are supported through the common pipeline path.
|
||||
- Normal tests do not require live S3.
|
||||
|
||||
## Stage 11: Cross-Backend End-to-End Coverage and Hardening Roadmap Extension
|
||||
|
||||
### Goal
|
||||
|
||||
Harden the MVP across backend combinations, destination policies, and failure cases.
|
||||
|
||||
### Scope
|
||||
|
||||
Add end-to-end coverage for representative scenarios:
|
||||
|
||||
- local source -> local archive destination;
|
||||
- local source -> local HTML destination;
|
||||
- local source -> two destinations with different publish policies;
|
||||
- local source -> SSH destination, where integration credentials exist;
|
||||
- local source -> S3 destination, where integration credentials exist;
|
||||
- S3 source -> local destination, where integration credentials exist;
|
||||
- SSH source -> local destination, where integration credentials exist.
|
||||
|
||||
Improve logging and error messages for:
|
||||
|
||||
- invalid config;
|
||||
- invalid source manifest;
|
||||
- digest mismatch;
|
||||
- destination conflict;
|
||||
- unmanaged destination path;
|
||||
- backend read/write/list failures;
|
||||
- transform failures.
|
||||
|
||||
Ensure all destructive paths have tests or explicit safeguards.
|
||||
|
||||
### Tests
|
||||
|
||||
Add or expand tests for:
|
||||
|
||||
- dry-run across multiple destinations;
|
||||
- partial failure behavior;
|
||||
- repeated run idempotency;
|
||||
- older/newer destination state behavior;
|
||||
- destination state output metadata accuracy;
|
||||
- generated HTML output metadata accuracy.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- MVP behavior is reliable across implemented backend types.
|
||||
- Error messages identify pipeline id, destination id, bundle id, and reason where practical.
|
||||
- Idempotent repeated runs behave as expected.
|
||||
|
||||
## Stage 12: User-Facing Documentation Sync
|
||||
|
||||
### Goal
|
||||
|
||||
Update documentation to reflect implemented MVP behavior.
|
||||
|
||||
### Scope
|
||||
|
||||
Following `docs/policy/documentation.md`, create or update user-facing documentation only for implemented features.
|
||||
|
||||
Likely docs:
|
||||
|
||||
```text
|
||||
README.md
|
||||
docs/config.md
|
||||
docs/cli.md
|
||||
docs/policy/architecture.md
|
||||
docs/internal/bundles.md
|
||||
docs/internal/backends.md
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
- what `distributor` does;
|
||||
- bundle contract summary;
|
||||
- `.distributor.json` role;
|
||||
- example source bundle;
|
||||
- example local-to-local config;
|
||||
- example local-to-S3 config;
|
||||
- example local-to-SSH config;
|
||||
- `run`, `validate`, and `inspect` commands;
|
||||
- dry-run behavior;
|
||||
- replacement and safety rules;
|
||||
- Markdown-to-HTML transform behavior;
|
||||
- environment-variable credential handling.
|
||||
|
||||
Move roadmap material to historical/planning status only if your documentation policy allows it. Do not describe unimplemented notification adapters as available features.
|
||||
|
||||
### Tests
|
||||
|
||||
Run the full test suite.
|
||||
|
||||
If docs include command examples, verify that basic examples correspond to actual CLI behavior.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- User-facing docs describe the implemented MVP accurately.
|
||||
- Roadmap docs no longer masquerade as implemented behavior.
|
||||
- `go test ./...` passes.
|
||||
|
||||
## Stage 13: MVP Release Readiness Pass
|
||||
|
||||
### Goal
|
||||
|
||||
Perform a final pre-release quality pass.
|
||||
|
||||
### Scope
|
||||
|
||||
Review:
|
||||
|
||||
- package boundaries against `docs/policy/architecture.md`;
|
||||
- package layout against `docs/roadmap/packages.md`;
|
||||
- implemented contracts against `docs/roadmap/contracts.md`;
|
||||
- implemented config behavior against `docs/roadmap/config.md`;
|
||||
- docs against `docs/policy/documentation.md`;
|
||||
- destructive operation safety;
|
||||
- logs and errors for unattended operation;
|
||||
- command UX;
|
||||
- test coverage for core invariants.
|
||||
|
||||
Add any missing small tests or docs discovered during review.
|
||||
|
||||
Do not add new product features in this stage.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- MVP is ready to deploy against one real producer pipeline.
|
||||
- A dry-run can be performed safely against a real source and destination.
|
||||
- Repeated runs are idempotent.
|
||||
- Destructive replacement cannot occur outside managed destination bundle paths.
|
||||
- Final docs accurately reflect the application.
|
||||
|
||||
## Stage 14: Explicit Force Overwrite Roadmap Extension
|
||||
|
||||
### Goal
|
||||
|
||||
Introduce explicit operator-requested force behavior for controlled overwrite cases that are intentionally outside the local MVP.
|
||||
|
||||
### Scope
|
||||
|
||||
Add a CLI-only force option such as:
|
||||
|
||||
```bash
|
||||
distributor run --config config.yml --force
|
||||
```
|
||||
|
||||
Define and implement force planning for:
|
||||
|
||||
- unmanaged non-empty destination paths;
|
||||
- destination state with a different source id;
|
||||
- destination state with matching source id and created timestamp but different digest;
|
||||
- destination state with mismatched `pipeline_id` or `destination_id`;
|
||||
- destination newer than source when the transfer policy explicitly allows replacement.
|
||||
|
||||
Force behavior must be explicit per run. It should not be a persistent default in config for this stage.
|
||||
|
||||
Update transfer policy validation to allow broader values only when force behavior is implemented and documented:
|
||||
|
||||
- `on_destination_newer: replace`
|
||||
- `on_conflict: replace`
|
||||
|
||||
### Safety Requirements
|
||||
|
||||
- Dry-run must show every file or object that would be written or deleted before a forced run.
|
||||
- Force must still never delete above the resolved destination bundle path or configured destination prefix.
|
||||
- Filesystem replacement should remain staged where practical.
|
||||
- S3 replacement must remain constrained to the destination bundle prefix.
|
||||
- Logs must clearly mark force decisions and include pipeline id, destination id, bundle id, and reason.
|
||||
|
||||
### Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- force rejected when the flag is absent;
|
||||
- unmanaged non-empty destination overwritten only with force;
|
||||
- different source id overwritten only with force and allowed policy;
|
||||
- same id and created but different digest overwritten only with force and allowed policy;
|
||||
- destination newer replaced only with force and allowed policy;
|
||||
- pipeline or destination id mismatch overwritten only with force and allowed policy;
|
||||
- dry-run reports destructive force actions without writing;
|
||||
- destructive paths remain bounded to the destination bundle path.
|
||||
|
||||
### Completion Criteria
|
||||
|
||||
- Force overwrite behavior is explicit, logged, dry-runnable, and test-covered.
|
||||
- Default non-force behavior remains unchanged and conservative.
|
||||
|
||||
## Deferred Post-MVP Work
|
||||
|
||||
The following items are intentionally outside the MVP unless explicitly pulled into a later roadmap:
|
||||
|
||||
- email notifications;
|
||||
- ntfy/Gotify/Pushover notifications;
|
||||
- RSS/Atom feed generation;
|
||||
- static site index pages beyond sidecar HTML output;
|
||||
- templated HTML themes beyond a minimal deterministic template;
|
||||
- destination path remapping rules;
|
||||
- full plugin architecture;
|
||||
- web UI;
|
||||
- report editing;
|
||||
- producer pipeline execution;
|
||||
- database-backed state;
|
||||
- complex retry queues;
|
||||
- concurrent publication workers;
|
||||
- symlink support;
|
||||
- external notification adapters;
|
||||
- warning-only digest mismatch handling;
|
||||
- password-based SSH authentication in YAML.
|
||||
- additional auth mechanisms beyond the implemented SSH and S3 credential
|
||||
paths;
|
||||
- compatibility parsing for legacy SSH URI config;
|
||||
- broad recursive destination deletion outside managed bundle paths;
|
||||
- concurrent fan-out publishing;
|
||||
- streaming, resumable, or multipart S3 uploads;
|
||||
- cloud-provider-specific IAM integration docs;
|
||||
- repository-managed packaging, release, and deployment automation.
|
||||
|
||||
## Roadmap Maintenance
|
||||
|
||||
When adding future roadmap work:
|
||||
|
||||
- describe user-visible behavior and safety boundaries;
|
||||
- define which existing docs must change after implementation;
|
||||
- keep examples secret-free and runnable or clearly environment-gated;
|
||||
- avoid workflow labels in production code, tests, config fields, and user
|
||||
documentation;
|
||||
- run focused tests for the changed behavior and `go test ./...` for
|
||||
cross-package changes.
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
# Package Layout Roadmap
|
||||
|
||||
This roadmap defines the proposed package layout, boundaries, and implementation responsibilities for the `distributor` MVP.
|
||||
|
||||
`distributor` is expected to be a domain-agnostic bundle publisher. Producer applications emit source bundles containing `manifest.json`; `distributor` validates those bundles and publishes selected source and generated artifacts to one or more configured destinations.
|
||||
|
||||
This document is roadmap material. It describes the intended package design before implementation and should move into `docs/internal/` only after corresponding behavior exists.
|
||||
|
||||
## Accepted Package Layout
|
||||
|
||||
```text
|
||||
cmd/distributor/
|
||||
main.go
|
||||
|
||||
internal/app/
|
||||
app.go
|
||||
run.go
|
||||
pipeline.go
|
||||
|
||||
internal/cli/
|
||||
root.go
|
||||
run.go
|
||||
validate.go
|
||||
inspect.go
|
||||
|
||||
internal/config/
|
||||
config.go
|
||||
defaults.go
|
||||
load.go
|
||||
validate.go
|
||||
|
||||
internal/bundle/
|
||||
manifest.go
|
||||
digest.go
|
||||
validate.go
|
||||
discover.go
|
||||
|
||||
internal/state/
|
||||
distributor.go
|
||||
compare.go
|
||||
validate.go
|
||||
|
||||
internal/storage/
|
||||
backend.go
|
||||
registry.go
|
||||
path.go
|
||||
errors.go
|
||||
|
||||
internal/storage/fake/
|
||||
backend.go
|
||||
|
||||
internal/adapters/local/
|
||||
backend.go
|
||||
|
||||
internal/adapters/ssh/
|
||||
backend.go
|
||||
config.go
|
||||
|
||||
internal/adapters/s3/
|
||||
backend.go
|
||||
config.go
|
||||
|
||||
internal/transform/
|
||||
transform.go
|
||||
registry.go
|
||||
plan.go
|
||||
|
||||
internal/transform/markdown/
|
||||
markdown.go
|
||||
template.go
|
||||
|
||||
internal/publish/
|
||||
plan.go
|
||||
execute.go
|
||||
output.go
|
||||
reconcile.go
|
||||
safety.go
|
||||
|
||||
internal/notify/
|
||||
notify.go
|
||||
noop.go
|
||||
|
||||
internal/logging/
|
||||
logging.go
|
||||
```
|
||||
|
||||
## Package Responsibilities
|
||||
|
||||
### `cmd/distributor`
|
||||
|
||||
Application entrypoint only.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- call CLI execution;
|
||||
- translate process exit status;
|
||||
- avoid business logic.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- config loading;
|
||||
- backend construction;
|
||||
- bundle validation;
|
||||
- publish decisions.
|
||||
|
||||
### `internal/cli`
|
||||
|
||||
CLI command definitions, flags, argument parsing, and command wiring.
|
||||
|
||||
Expected MVP commands:
|
||||
|
||||
- `distributor run` — run configured pipelines.
|
||||
- `distributor run --dry-run` — plan without modifying destinations.
|
||||
- `distributor run --pipeline <id>` — run one configured pipeline.
|
||||
- `distributor validate <path>` — validate a source bundle or source tree where feasible.
|
||||
- `distributor inspect <path>` — inspect a bundle or destination state where feasible.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- CLI should call `internal/app` use cases.
|
||||
- CLI should not parse manifests directly except through application APIs.
|
||||
- CLI should not import backend adapter implementation details unless only for registration side effects.
|
||||
|
||||
### `internal/app`
|
||||
|
||||
Application orchestration and top-level use cases.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- load and validate configuration;
|
||||
- construct configured pipelines;
|
||||
- build source and destination backends through registries;
|
||||
- orchestrate discovery, validation, planning, publishing, and notification;
|
||||
- coordinate dry-run output;
|
||||
- run destination fan-out deterministically and sequentially;
|
||||
- aggregate destination outcomes into run-level failure behavior.
|
||||
|
||||
Core orchestration shape:
|
||||
|
||||
```text
|
||||
for each selected pipeline:
|
||||
open source backend
|
||||
discover source bundles
|
||||
for each source bundle:
|
||||
validate source manifest and digest
|
||||
for each destination:
|
||||
inspect .distributor.json
|
||||
build publish plan
|
||||
transform as required by that destination
|
||||
execute publish plan unless dry-run
|
||||
run noop notifier after actual publication or replacement
|
||||
```
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `internal/app` composes packages but should not contain backend-specific logic.
|
||||
- Publish decisions should live in `internal/publish`, not inline in orchestration.
|
||||
- Destination state comparison should live in `internal/state` or `internal/publish`, not CLI code.
|
||||
|
||||
### `internal/config`
|
||||
|
||||
Configuration structs, defaults, loading, precedence, and validation.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- load `/usr/local/etc/distributor/config.yml` by default;
|
||||
- support `--config` override;
|
||||
- apply defaults;
|
||||
- validate required fields;
|
||||
- validate pipeline ids and destination ids;
|
||||
- validate backend-specific config shapes;
|
||||
- validate transform and publish policy combinations.
|
||||
|
||||
MVP config model:
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
- id: weather-daily
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/weather
|
||||
validation:
|
||||
on_digest_mismatch: fail
|
||||
destinations:
|
||||
- id: markdown-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: weather/archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
- id: static-site
|
||||
backend: ssh
|
||||
uri: ssh://deploy@example.com:22
|
||||
path: /srv/www/weather
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
```
|
||||
|
||||
Configuration principles:
|
||||
|
||||
- one source per pipeline;
|
||||
- one or more destinations per pipeline;
|
||||
- transforms are destination-specific;
|
||||
- publish policy is destination-specific;
|
||||
- secrets should use environment variables, secret files, SSH agent, or standard credential mechanisms rather than raw YAML values.
|
||||
|
||||
### `internal/bundle`
|
||||
|
||||
Source bundle contract and validation.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- parse source `manifest.json`;
|
||||
- represent source manifests and files;
|
||||
- discover bundle roots beneath a configured source root;
|
||||
- validate required manifest fields;
|
||||
- validate RFC3339 `created` values;
|
||||
- validate relative paths;
|
||||
- validate file existence, size, per-file SHA-256, and bundle digest;
|
||||
- expose normalized source bundle models to other packages.
|
||||
|
||||
Core types:
|
||||
|
||||
```go
|
||||
type Manifest struct {
|
||||
SchemaVersion int
|
||||
ID string
|
||||
Digest string
|
||||
Created time.Time
|
||||
Files []ManifestFile
|
||||
}
|
||||
|
||||
type ManifestFile struct {
|
||||
Path string
|
||||
SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
RootRelativePath string
|
||||
Manifest Manifest
|
||||
}
|
||||
```
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `internal/bundle` does not know about `.distributor.json`.
|
||||
- `internal/bundle` does not know about destinations, transforms, or notification.
|
||||
- `internal/bundle` may use the storage abstraction to read source files, but it should not import backend adapter packages.
|
||||
|
||||
### `internal/state`
|
||||
|
||||
Destination state contract for `.distributor.json`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- parse `.distributor.json`;
|
||||
- validate destination state;
|
||||
- represent copied source outputs and generated outputs;
|
||||
- embed the source manifest used for publication;
|
||||
- compare destination state against a current source manifest;
|
||||
- classify destination state as same, older, newer, conflict, absent, invalid, or unmanaged.
|
||||
|
||||
Core types:
|
||||
|
||||
```go
|
||||
type DistributorState struct {
|
||||
SchemaVersion int
|
||||
DistributorVersion string
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
PublishedAt time.Time
|
||||
Source SourceState
|
||||
Outputs []OutputFile
|
||||
}
|
||||
|
||||
type SourceState struct {
|
||||
Manifest bundle.Manifest
|
||||
}
|
||||
|
||||
type OutputFile struct {
|
||||
Path string
|
||||
Kind string // source | generated
|
||||
SourcePath string
|
||||
Transform string
|
||||
SHA256 string
|
||||
Size int64
|
||||
}
|
||||
```
|
||||
|
||||
Comparison rules:
|
||||
|
||||
- same source manifest: skip;
|
||||
- same source id, older destination source `created`: replace;
|
||||
- same source id, newer destination source `created`: skip;
|
||||
- same source id, same `created`, different digest: conflict;
|
||||
- different source id: conflict;
|
||||
- pipeline id or destination id mismatch: conflict;
|
||||
- absent state: publish only if safe;
|
||||
- unmanaged non-empty path: fail.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `internal/state` owns destination state semantics, not publish execution.
|
||||
- `internal/state` should not know about S3, SSH/SFTP, local filesystem details, or Markdown rendering.
|
||||
|
||||
### `internal/storage`
|
||||
|
||||
Backend abstraction and shared storage types.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- define storage backend interfaces;
|
||||
- define object/file metadata types;
|
||||
- define path/prefix helpers;
|
||||
- define common storage errors;
|
||||
- provide backend registry mechanisms;
|
||||
- provide a fake backend for core package tests.
|
||||
|
||||
The detailed storage contract is defined in `docs/roadmap/storage.md`. Core application code should use that storage interface for backend-rooted logical paths, byte and stream IO, metadata, traversal, typed errors, emptiness checks, and managed deletion.
|
||||
|
||||
Destructive APIs should remain narrow. Prefer managed deletion of files recorded in `.distributor.json` instead of broad recursive deletion.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `internal/storage` should not contain backend implementation details.
|
||||
- Adapter dependencies must not leak through storage interfaces.
|
||||
- The fake backend exists for tests and should not become an application runtime backend.
|
||||
|
||||
### `internal/adapters/local`
|
||||
|
||||
Local filesystem backend.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- implement `storage.Backend` for local paths;
|
||||
- clean and constrain paths;
|
||||
- perform safe reads/writes/listing/deletion;
|
||||
- use atomic writes where practical;
|
||||
- reject unsafe path traversal;
|
||||
- handle symlink policy explicitly.
|
||||
|
||||
Testing expectations:
|
||||
|
||||
- use temporary directories;
|
||||
- verify path traversal rejection;
|
||||
- verify write and delete safety.
|
||||
|
||||
### `internal/adapters/ssh`
|
||||
|
||||
SSH/SFTP backend.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- implement `storage.Backend` over SSH/SFTP;
|
||||
- support `uri` and `path` config;
|
||||
- prefer native SFTP implementation;
|
||||
- use SSH agent, key files, known hosts, or documented auth mechanisms;
|
||||
- avoid raw passwords in config unless explicitly designed and documented later;
|
||||
- translate SSH/SFTP errors into storage-level errors.
|
||||
|
||||
Testing expectations:
|
||||
|
||||
- core app tests should use fake backends;
|
||||
- adapter tests may use local test servers or targeted integration tests if practical;
|
||||
- do not require a real production SSH host for normal unit tests.
|
||||
|
||||
### `internal/adapters/s3`
|
||||
|
||||
S3-compatible object storage backend.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- implement `storage.Backend` over S3-compatible object storage;
|
||||
- support endpoint, bucket, prefix, region, and force-path-style configuration;
|
||||
- support standard credential mechanisms or explicit environment-variable references;
|
||||
- treat S3 as an object tree, not a filesystem;
|
||||
- set reasonable content types where practical;
|
||||
- guard against prefix/root deletion mistakes.
|
||||
|
||||
Testing expectations:
|
||||
|
||||
- core app tests should use fake backends;
|
||||
- adapter behavior may be tested through mocks, local S3-compatible services, or narrow integration tests;
|
||||
- config examples should avoid real secrets.
|
||||
|
||||
### `internal/transform`
|
||||
|
||||
Transform interfaces, registry, and transform planning.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- define transform interfaces;
|
||||
- register available transforms;
|
||||
- represent transform requests and outputs;
|
||||
- keep transform execution independent of destination backend details.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- transforms operate on source bundle content and destination transform config;
|
||||
- transforms do not publish outputs;
|
||||
- transforms do not mutate source bundles;
|
||||
- transforms should return generated output metadata for `.distributor.json`.
|
||||
|
||||
### `internal/transform/markdown`
|
||||
|
||||
Markdown-to-HTML implementation.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- render listed Markdown files to HTML;
|
||||
- support MVP sidecar behavior, such as `report.md` -> `report.html`;
|
||||
- record generated output path, source path, transform name, SHA-256, and size;
|
||||
- optionally use embedded templates if needed.
|
||||
|
||||
MVP scope:
|
||||
|
||||
- Markdown to HTML only;
|
||||
- no PDF generation;
|
||||
- no email-specific HTML;
|
||||
- no complex theming unless required for basic output correctness.
|
||||
|
||||
### `internal/publish`
|
||||
|
||||
Destination planning, reconciliation, safety checks, and publish execution.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- inspect destination state;
|
||||
- plan destination action;
|
||||
- enforce destination conflict rules;
|
||||
- enforce destructive-operation safety rules;
|
||||
- detect output path collisions before writing;
|
||||
- combine source files and transform outputs according to destination publish policy;
|
||||
- write destination outputs;
|
||||
- write `.distributor.json`;
|
||||
- use staging or equivalent cleanup behavior where practical;
|
||||
- support dry-run planning;
|
||||
- report skipped, replaced, failed, and published actions.
|
||||
|
||||
Action model:
|
||||
|
||||
```text
|
||||
publish
|
||||
replace
|
||||
skip_same
|
||||
skip_destination_newer
|
||||
fail_conflict
|
||||
fail_unmanaged
|
||||
```
|
||||
|
||||
Boundaries:
|
||||
|
||||
- publish logic should not parse CLI flags;
|
||||
- publish logic should not know adapter implementation details;
|
||||
- publish logic should use `internal/state` for destination state semantics;
|
||||
- publish logic should use `internal/storage` interfaces for IO.
|
||||
|
||||
### `internal/notify`
|
||||
|
||||
Notification stage abstraction.
|
||||
|
||||
MVP responsibilities:
|
||||
|
||||
- define notifier interface;
|
||||
- implement no-op notifier;
|
||||
- preserve future extension point for email, ntfy, Gotify, RSS update hooks, or other notification channels.
|
||||
|
||||
Future notification rules:
|
||||
|
||||
- notify only after successful publication to the relevant destination or destinations;
|
||||
- notification must be idempotent with respect to source id, digest, pipeline id, and destination id where applicable;
|
||||
- notification should not run for skipped or failed publications unless explicitly configured.
|
||||
|
||||
### `internal/logging`
|
||||
|
||||
Logging setup and helpers.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- centralize structured logging setup;
|
||||
- ensure logs omit secrets;
|
||||
- provide consistent fields for pipeline id, bundle id, destination id, backend, path, action, and reason.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
The following are intentionally out of MVP unless separately accepted in a later roadmap:
|
||||
|
||||
- email, ntfy, Gotify, or other real notification adapters;
|
||||
- RSS/Atom feed generation;
|
||||
- PDF generation;
|
||||
- web UI;
|
||||
- full-text search;
|
||||
- dynamic plugin loading;
|
||||
- arbitrary transform chains;
|
||||
- workflow DAGs;
|
||||
- producer execution;
|
||||
- complex templating/theming;
|
||||
- bidirectional sync;
|
||||
- backup semantics.
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- Producer apps own source bundle creation.
|
||||
- `distributor` owns destination publication state.
|
||||
- Source `manifest.json` is not copied as destination state.
|
||||
- Destination `.distributor.json` is the managed sentinel.
|
||||
- One pipeline has one source and one or more destinations.
|
||||
- Transform and publish policy are destination-specific.
|
||||
- Source files are canonical; HTML is derived.
|
||||
- Destructive replacement is allowed only inside managed destination bundle paths.
|
||||
- Core logic must be testable without real S3, SSH, or remote services.
|
||||
@@ -1,265 +0,0 @@
|
||||
# Storage Interface Roadmap
|
||||
|
||||
This roadmap defines the planned `internal/storage` contract for the `distributor` MVP. The goal is to give bundle validation, destination state inspection, publish planning, and backend adapters one consistent IO boundary without leaking local filesystem, SSH/SFTP, or S3-specific behavior into core packages.
|
||||
|
||||
## Purpose and Invariants
|
||||
|
||||
The storage layer is responsible for safe, backend-rooted access to files, objects, prefixes, and destination bundle paths.
|
||||
|
||||
Core invariants:
|
||||
|
||||
- Backends are opened at configured roots.
|
||||
- Core packages operate on backend-rooted logical paths, not absolute filesystem paths or raw object keys.
|
||||
- Backend adapters translate native storage behavior into common storage entries and typed errors.
|
||||
- Destructive operations remain narrow and managed.
|
||||
- Staging or atomic write behavior belongs behind the storage interface where practical.
|
||||
- The fake backend exists for tests only and must not be registered as a runtime backend.
|
||||
|
||||
## Logical Path Model
|
||||
|
||||
Storage paths are slash-separated logical paths relative to an already configured backend root.
|
||||
|
||||
File paths:
|
||||
|
||||
- must be non-empty;
|
||||
- must be relative;
|
||||
- must be clean;
|
||||
- must not contain `.` or `..` segments;
|
||||
- must not start with `/`;
|
||||
- must not contain backslashes;
|
||||
- must not resolve outside the backend root.
|
||||
|
||||
Prefix paths use the same slash-separated model. A prefix may be empty to represent the backend root for traversal and destination emptiness checks.
|
||||
|
||||
Prefix matching must preserve logical path boundaries. A prefix of `foo` matches `foo` and entries below `foo/`; it must not match a sibling path such as `foobar`. Backends that map logical paths to object keys must apply the same normalized boundary rule after combining configured backend prefixes with caller-provided logical prefixes.
|
||||
|
||||
Backends own conversion from logical paths to native paths or object keys. Core packages should not construct local filesystem paths, SFTP paths, or S3 object keys directly.
|
||||
|
||||
## Core Interface Shape
|
||||
|
||||
The MVP should use a hybrid byte and stream interface:
|
||||
|
||||
```go
|
||||
type Backend interface {
|
||||
ReadFile(ctx context.Context, path string) ([]byte, error)
|
||||
OpenReader(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
WriteFile(ctx context.Context, path string, data []byte, opts WriteOptions) (Entry, error)
|
||||
WriteFrom(ctx context.Context, path string, r io.Reader, opts WriteOptions) (Entry, error)
|
||||
Stat(ctx context.Context, path string) (Entry, error)
|
||||
Walk(ctx context.Context, prefix string, opts WalkOptions, fn WalkFunc) error
|
||||
HasAny(ctx context.Context, prefix string) (bool, error)
|
||||
DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts DeleteOptions) error
|
||||
}
|
||||
```
|
||||
|
||||
Byte helpers are expected to cover manifests, destination state, small source artifacts, and generated outputs. Stream methods are included from the start for backend flexibility and larger future artifacts.
|
||||
|
||||
Write operations should create required parent directories or prefixes as needed.
|
||||
|
||||
Concrete option and callback types should use this shape:
|
||||
|
||||
```go
|
||||
type WalkOptions struct {
|
||||
Recursive bool
|
||||
Limit int
|
||||
}
|
||||
|
||||
type WalkFunc func(Entry) error
|
||||
|
||||
var ErrStopWalk = errors.New("stop walk")
|
||||
|
||||
type WriteOptions struct {
|
||||
ContentType string
|
||||
Overwrite bool
|
||||
PreferAtomic bool
|
||||
Size int64
|
||||
SizeKnown bool
|
||||
}
|
||||
|
||||
type DeleteOptions struct {
|
||||
IgnoreMissing bool
|
||||
PruneEmptyDirs bool
|
||||
}
|
||||
```
|
||||
|
||||
`WalkOptions.Limit == 0` means no explicit limit. `SizeKnown` applies primarily to `WriteFrom`; byte writes can infer size from the provided data.
|
||||
|
||||
## Entries and Metadata
|
||||
|
||||
Storage metadata should be represented by an `Entry` model with at least:
|
||||
|
||||
- backend-relative logical path;
|
||||
- entry type;
|
||||
- size, where available.
|
||||
|
||||
Entry types:
|
||||
|
||||
- `file`: filesystem file or object-storage object;
|
||||
- `directory`: filesystem directory or logical prefix;
|
||||
- `symlink`: local filesystem symlink;
|
||||
- `other`: unknown or unsupported native entry type.
|
||||
|
||||
`Stat` returns metadata for one exact logical path. It may report a real filesystem directory, symlink, file, or exact object. It must not synthesize S3-like directory metadata solely because objects exist below a prefix; callers that need prefix existence or destination emptiness must use `HasAny` or `Walk`.
|
||||
|
||||
`Walk` traverses entries below a prefix and calls a callback for each entry. `WalkOptions` should include:
|
||||
|
||||
- whether traversal is recursive;
|
||||
- an optional entry limit for callers that only need to know whether content exists.
|
||||
|
||||
If a callback returns `ErrStopWalk`, traversal stops successfully and `Walk` returns nil. Any other callback error stops traversal and is returned with storage context where practical. If `WalkOptions.Limit` is greater than zero, reaching the limit stops traversal successfully.
|
||||
|
||||
Backends may stream or paginate traversal internally. S3-compatible adapters should not need to load a whole prefix into memory to satisfy traversal.
|
||||
|
||||
Raw traversal is not required to be lexically sorted. A helper that materializes walk results for bundle discovery, tests, or CLI output should sort entries lexically by logical path before returning them.
|
||||
|
||||
`HasAny` reports whether at least one entry exists below a prefix. It should stop as soon as content is found.
|
||||
|
||||
Source validation must reject symlink entries reported by local `Stat` or `Walk`.
|
||||
|
||||
## Read Behavior
|
||||
|
||||
`ReadFile` reads the whole object into memory and is appropriate for MVP manifest, state, and ordinary artifact handling.
|
||||
|
||||
`OpenReader` returns a stream for callers that need to copy or hash content without requiring a second storage-specific API. Callers must close the returned reader.
|
||||
|
||||
Both read methods must:
|
||||
|
||||
- validate logical paths before backend access;
|
||||
- reject directories, prefixes, symlinks, and unsupported entries;
|
||||
- return typed not-found and invalid-path errors where applicable.
|
||||
|
||||
## Write Behavior
|
||||
|
||||
`WriteOptions` should include:
|
||||
|
||||
- content type, when the destination backend can use it;
|
||||
- overwrite permission;
|
||||
- atomic or staged write preference;
|
||||
- optional known size for stream writes.
|
||||
|
||||
Backends own staging and atomic behavior where practical:
|
||||
|
||||
- Local backend writes to a temporary file in the destination directory and renames or promotes into place.
|
||||
- SSH/SFTP backend should use a temporary remote file and rename where available.
|
||||
- S3-compatible backend treats a successful object PUT as publish-on-success and applies content type metadata.
|
||||
|
||||
Remote adapters may buffer or spool `WriteFrom` input when needed to satisfy backend requirements such as content length, multipart upload, or retry behavior. Callers that know the stream size should set `SizeKnown` and `Size`.
|
||||
|
||||
If overwrite is false and the target exists, writes should fail with an already-exists error.
|
||||
|
||||
`WriteFile` and `WriteFrom` should return the written `Entry`, including final path and size where available.
|
||||
|
||||
`DeleteOptions` should include:
|
||||
|
||||
- whether missing managed output paths are ignored;
|
||||
- whether empty parent directories may be pruned for filesystem-like backends.
|
||||
|
||||
## Managed Deletion
|
||||
|
||||
The storage interface should expose a guarded managed deletion operation rather than raw recursive delete.
|
||||
|
||||
`DeleteManagedBundle(ctx, bundlePath, managedOutputPaths, opts)` may delete only:
|
||||
|
||||
- files or objects listed in valid `.distributor.json.outputs`;
|
||||
- `.distributor.json` at the destination bundle path;
|
||||
- empty directories created by those files, for filesystem-like backends.
|
||||
|
||||
`managedOutputPaths` are relative to the destination bundle path. The backend validates each path and resolves it under `bundlePath`.
|
||||
|
||||
If `bundlePath == ""`, deletion may remove explicit managed files at the destination root, but must never delete the root itself.
|
||||
|
||||
Prefix or recursive deletion is out of MVP scope. A future force-overwrite stage may add broader behavior, but it must remain explicit and separately documented.
|
||||
|
||||
## Destination Emptiness
|
||||
|
||||
Destination emptiness should use `HasAny(prefix)` and typed not-found behavior. Callers that only need emptiness must not materialize a full recursive traversal.
|
||||
|
||||
Rules:
|
||||
|
||||
- A local destination bundle path is empty when the directory does not exist or exists with no entries.
|
||||
- An S3-compatible prefix is empty when no objects exist below that exact destination bundle prefix.
|
||||
- Entries outside the exact destination bundle path or prefix do not affect emptiness.
|
||||
|
||||
## Error Model
|
||||
|
||||
Storage should expose typed error categories with wrapping context. Callers should use helper predicates rather than string matching.
|
||||
|
||||
Required categories:
|
||||
|
||||
- not found;
|
||||
- already exists;
|
||||
- not empty;
|
||||
- invalid path;
|
||||
- conflict;
|
||||
- permission;
|
||||
- temporary;
|
||||
- unsupported;
|
||||
- unknown.
|
||||
|
||||
Adapters should translate backend-native errors into these categories while preserving useful operation, backend, path, and cause context.
|
||||
|
||||
## Adapter Expectations
|
||||
|
||||
### Local
|
||||
|
||||
The local backend should:
|
||||
|
||||
- constrain all operations beneath the configured root;
|
||||
- reject traversal and absolute logical paths;
|
||||
- report symlinks through metadata;
|
||||
- reject symlink reads for source artifacts;
|
||||
- use staged writes where practical;
|
||||
- perform managed deletion only for explicit managed files and `.distributor.json`;
|
||||
- clean up empty directories created by managed outputs where safe.
|
||||
|
||||
### Fake
|
||||
|
||||
The fake backend should:
|
||||
|
||||
- be in-memory and deterministic;
|
||||
- implement the same logical path validation rules;
|
||||
- support `Stat`, `Walk`, `HasAny`, byte reads and writes, stream reads and writes, managed deletion, and destination emptiness helper behavior;
|
||||
- support configured symlink entries for validation tests;
|
||||
- be used only by tests.
|
||||
|
||||
### SSH/SFTP
|
||||
|
||||
The SSH/SFTP backend should:
|
||||
|
||||
- use native SFTP operations;
|
||||
- enforce the same logical path rules as local storage;
|
||||
- use temporary file plus rename for staged writes where available;
|
||||
- translate remote errors into storage error categories;
|
||||
- avoid exposing SSH or SFTP dependency types through `internal/storage`.
|
||||
|
||||
### S3-Compatible
|
||||
|
||||
The S3-compatible backend should:
|
||||
|
||||
- treat prefixes as object trees, not real directories;
|
||||
- normalize configured prefix plus logical path into object keys;
|
||||
- use object PUT as publish-on-success;
|
||||
- set content type from `WriteOptions`;
|
||||
- implement traversal and emptiness by exact prefix;
|
||||
- use backend pagination for traversal where available;
|
||||
- allow `HasAny` to stop after the first matching object;
|
||||
- constrain managed deletion to listed output objects and `.distributor.json`.
|
||||
|
||||
## Tests and Fixtures
|
||||
|
||||
Storage implementation stages should test:
|
||||
|
||||
- path validation rejects absolute paths, traversal, empty file paths, backslashes, and dot segments;
|
||||
- `Walk` visits backend-rooted logical paths under a prefix and supports recursive traversal;
|
||||
- a materializing helper sorts walk results lexically for deterministic tests and CLI output;
|
||||
- `HasAny` returns quickly for non-empty prefixes without requiring full traversal;
|
||||
- `ReadFile` and `OpenReader` return equivalent bytes;
|
||||
- `WriteFile` and `WriteFrom` honor overwrite and content-type options;
|
||||
- local staged writes do not leave final files on failure where testable;
|
||||
- managed deletion deletes only state-listed files and `.distributor.json`;
|
||||
- managed deletion never deletes destination root or unlisted files;
|
||||
- destination emptiness helper handles missing, empty, and non-empty local paths;
|
||||
- S3-compatible traversal can use pagination without loading a whole prefix into memory;
|
||||
- symlink entries are reported and rejected by source validation;
|
||||
- typed errors are usable through helper predicates;
|
||||
- fake backend behavior matches local backend semantics relevant to core tests.
|
||||
321
docs/troubleshooting.md
Normal file
321
docs/troubleshooting.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Distributor Troubleshooting
|
||||
|
||||
## `load config ... no such file or directory`
|
||||
|
||||
Likely cause: `run` could not find the config path. If `--config` is omitted, the default path is `/usr/local/etc/distributor/config.yml`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -l <config-path>
|
||||
```
|
||||
|
||||
Safe fix: pass an existing config path with `--config`, or install a config at the default path. See [configuration](config.md).
|
||||
|
||||
## `parse config ... field not found`
|
||||
|
||||
Likely cause: the YAML contains an unknown field. Config loading rejects unknown keys.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: compare the file to the reference in [configuration](config.md) and remove or rename unsupported fields.
|
||||
|
||||
## `validate config ... backend ... is unsupported`
|
||||
|
||||
Likely cause: a source or destination uses a backend name other than `local`, `ssh`, or `s3`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
rg -n "backend:" <config-path>
|
||||
```
|
||||
|
||||
Safe fix: use `backend: local`, `backend: ssh`, or `backend: s3` for executable workflows.
|
||||
|
||||
## `prefix must be a clean relative slash-separated path`
|
||||
|
||||
Likely cause: S3 `prefix` contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: use a clean relative prefix such as `reports/archive`, or omit `prefix`.
|
||||
|
||||
## `NoSuchBucket`, `InvalidBucketName`, or `not_found`
|
||||
|
||||
Likely cause: the S3 bucket, endpoint, or prefix is wrong, or the configured credentials cannot see the requested object.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: verify `endpoint`, `bucket`, `region`, `force_path_style`, and `prefix`. For S3-compatible services, keep `force_path_style: true` unless the service requires virtual-host addressing.
|
||||
|
||||
## `AccessDenied`, `InvalidAccessKeyId`, or `SignatureDoesNotMatch`
|
||||
|
||||
Likely cause: S3 credentials are missing, wrong, empty, or lack permission for the bucket or prefix.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^(<access-key-variable>|<secret-key-variable>)$'
|
||||
ls -l <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: provide both configured credential environment variables through the real environment or `secrets.directory`, or omit explicit credential fields to use the AWS SDK default credential chain.
|
||||
|
||||
## S3 endpoint connection failures
|
||||
|
||||
Likely cause: the endpoint URL is unreachable, uses the wrong scheme, or does not match the configured path-style mode.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
curl -I <endpoint>
|
||||
```
|
||||
|
||||
Safe fix: correct `endpoint`, network routing, TLS settings outside distributor, or `force_path_style`. Distributor does not provide insecure TLS bypass configuration.
|
||||
|
||||
## `load secrets directory ... no such file or directory`
|
||||
|
||||
Likely cause: `secrets.directory` points to a missing directory.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -ld <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: create or mount the directory before running, or remove `secrets.directory` if no credential files are needed.
|
||||
|
||||
## `load secrets directory ... permission denied`
|
||||
|
||||
Likely cause: the service user cannot read the configured secrets directory.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -ld <secrets-directory>
|
||||
namei -l <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: adjust the directory path or deployment permissions so the service user can read the directory. Distributor does not enforce owner, group, or mode policy beyond OS read access.
|
||||
|
||||
## `secret filename ... is invalid`
|
||||
|
||||
Likely cause: a regular file in `secrets.directory` does not match `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
find <secrets-directory> -maxdepth 1 -type f -printf '%f\n'
|
||||
```
|
||||
|
||||
Safe fix: rename the file to a valid credential environment variable name, or remove it from the secrets directory.
|
||||
|
||||
## `credential environment variable ... is not set`
|
||||
|
||||
Likely cause: a backend credential field references an environment variable that is absent from both the real process environment and the configured secrets directory.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^<variable-name>$'
|
||||
ls -l <secrets-directory>/<variable-name>
|
||||
```
|
||||
|
||||
Safe fix: set the real environment variable or create a readable secrets-directory file with the same name.
|
||||
|
||||
## `secret ... ignored because the real environment already has that variable`
|
||||
|
||||
Likely cause: the real process environment and secrets directory both define the variable with different values.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^<variable-name>$'
|
||||
ls -l <secrets-directory>/<variable-name>
|
||||
```
|
||||
|
||||
Safe fix: remove one source of the credential or make the deployment intentionally prefer the real environment value. Distributor does not print either value.
|
||||
|
||||
## `host is required for ssh backend`
|
||||
|
||||
Likely cause: SSH config is missing the structured `host` field, or an old URL-style 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`. SSH URLs are not part of the active config schema.
|
||||
|
||||
## `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`
|
||||
|
||||
Likely cause: `validate` or `inspect` was run without a path.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --help
|
||||
go run ./cmd/distributor inspect --help
|
||||
```
|
||||
|
||||
Safe fix: pass a local source bundle directory or a local tree containing source bundles.
|
||||
|
||||
## `no bundles found under "."`
|
||||
|
||||
Likely cause: the selected source root does not contain a `manifest.json` source bundle.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
find <source-root> -name manifest.json -print
|
||||
```
|
||||
|
||||
Safe fix: point the command or config at the directory containing the source bundle, or write a valid `manifest.json` and listed files. See [CLI](cli.md).
|
||||
|
||||
## `sha256 mismatch`, `size mismatch`, or `digest mismatch`
|
||||
|
||||
Likely cause: a listed source file changed after `manifest.json` was created, or the manifest digest does not match its file list.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate <source-root>
|
||||
```
|
||||
|
||||
Safe fix: regenerate the producer bundle and manifest together. Do not edit destination state to work around source digest failures.
|
||||
|
||||
## `destination has content but no distributor state`
|
||||
|
||||
Likely cause: the destination path is not empty and has no `.distributor.json` state file, so `distributor` will not claim it as managed.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
find <destination-path> -maxdepth 2 -print
|
||||
```
|
||||
|
||||
Safe fix: choose an empty destination path or move existing files aside after confirming they are not needed. If the destination should be claimed by distributor, preview with `run --dry-run --force` and publish with `run --force` only after confirming the reported `force_replace` action is bounded to the intended bundle path.
|
||||
|
||||
## `fail_conflict`
|
||||
|
||||
Likely cause: existing `.distributor.json` belongs to a different pipeline, a different destination, a different source id, or a same-created source with a different digest.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
cat <destination-path>/.distributor.json
|
||||
go run ./cmd/distributor inspect <source-root>
|
||||
```
|
||||
|
||||
Safe fix: verify you are publishing the intended source to the intended destination. Use a separate destination path for unrelated content. If the existing state should be replaced, configure `transfer.on_conflict: replace`, preview with `run --dry-run --force`, then publish with `run --force`.
|
||||
|
||||
## `destination is newer and replacement requires --force`
|
||||
|
||||
Likely cause: config explicitly allows newer-destination replacement, but the current run did not include `--force`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --force
|
||||
```
|
||||
|
||||
Safe fix: prefer the default `transfer.on_destination_newer: skip` unless replacing newer destination state is intentional. To replace it, keep `transfer.on_destination_newer: replace`, confirm the dry-run output shows `force_replace`, then run with `--force`.
|
||||
|
||||
## `force_replace`
|
||||
|
||||
Likely cause: the current run used `--force` and publish planning selected a supported destructive replacement.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --force
|
||||
```
|
||||
|
||||
Safe fix: inspect the printed pipeline id, destination id, backend, and bundle path. Proceed only if deleting all content within that destination bundle path is intended.
|
||||
|
||||
## `destination output path collision`
|
||||
|
||||
Likely cause: configured publication would write two outputs to the same destination path, such as publishing a source `report.html` while also generating `report.html` from `report.md`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: adjust the source bundle contents or publish policy so source and generated outputs do not collide.
|
||||
|
||||
## A run failed after writing some files
|
||||
|
||||
Likely cause: a write failed partway through publication. Local, SSH, and S3 execution attempt to clean up outputs written during the failed attempt.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
find <destination-path> -maxdepth 2 -print
|
||||
```
|
||||
|
||||
Safe fix: use the pipeline id, destination id, backend, and bundle path printed in the run error to inspect the destination before retrying. If only unrelated unmanaged files remain, move them aside or choose a clean destination. Re-run with `--dry-run` before publishing again. See [operations](operations.md).
|
||||
22
examples/fan-out.yml
Normal file
22
examples/fan-out.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
pipelines:
|
||||
- id: example-fan-out
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: local-source-archive
|
||||
backend: local
|
||||
path: workspace/published/fan-out/source
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
- id: local-html-site
|
||||
backend: local
|
||||
path: workspace/published/fan-out/html
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
16
examples/local-html.yml
Normal file
16
examples/local-html.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
pipelines:
|
||||
- id: example-html-bundle
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: local-html
|
||||
backend: local
|
||||
path: workspace/published/html-bundle
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
12
examples/local-publish.yml
Normal file
12
examples/local-publish.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
pipelines:
|
||||
- id: example-source-bundle
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: local-archive
|
||||
backend: local
|
||||
path: workspace/published/source-bundle
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
9
examples/local-to-local.yml
Normal file
9
examples/local-to-local.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/distributor/reports
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
22
examples/s3-destination.yml
Normal file
22
examples/s3-destination.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Environment-gated example.
|
||||
# Replace endpoint, bucket, prefix, and credential environment variable names
|
||||
# with values for an S3-compatible service you control before running this config.
|
||||
pipelines:
|
||||
- id: example-s3-destination
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: s3-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: distributor/archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
18
examples/source-bundle/manifest.json
Normal file
18
examples/source-bundle/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe",
|
||||
"created": "2026-05-30T11:10:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "report.md",
|
||||
"sha256": "sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6",
|
||||
"size": 16
|
||||
},
|
||||
{
|
||||
"path": "summary.txt",
|
||||
"sha256": "sha256:3cbb36aca330b3bd113955dfbada0adb7a5f95ad9f678bd61f175406c6a37e95",
|
||||
"size": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
2
examples/source-bundle/report.md
Normal file
2
examples/source-bundle/report.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Report
|
||||
Sunny.
|
||||
1
examples/source-bundle/summary.txt
Normal file
1
examples/source-bundle/summary.txt
Normal file
@@ -0,0 +1 @@
|
||||
Summary
|
||||
21
examples/ssh-destination.yml
Normal file
21
examples/ssh-destination.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Environment-gated example.
|
||||
# Replace host, user, path, ssh_key_file, and known_hosts with values for an
|
||||
# SSH/SFTP endpoint you control before running this config.
|
||||
pipelines:
|
||||
- id: example-ssh-destination
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: ssh-archive
|
||||
backend: ssh
|
||||
host: ssh.example.com
|
||||
user: distributor
|
||||
port: 22
|
||||
path: /srv/distributor/archive
|
||||
ssh_key_file: /home/distributor/.ssh/id_ed25519
|
||||
known_hosts: /home/distributor/.ssh/known_hosts
|
||||
host_key_policy: strict
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
33
go.mod
Normal file
33
go.mod
Normal file
@@ -0,0 +1,33 @@
|
||||
module gitea.maximumdirect.net/eric/distributor
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
|
||||
github.com/aws/smithy-go v1.26.0
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/crypto v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
)
|
||||
58
go.sum
Normal file
58
go.sum
Normal file
@@ -0,0 +1,58 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
403
internal/adapters/local/backend.go
Normal file
403
internal/adapters/local/backend.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
const backendName = "local"
|
||||
|
||||
type Backend struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func New(root string) (*Backend, error) {
|
||||
if root == "" {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, backendName, "", storage.ErrInvalidPath, nil)
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, backendName, root, storage.ErrInvalidPath, err)
|
||||
}
|
||||
return &Backend{root: filepath.Clean(absRoot)}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||
reader, err := b.OpenReader(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpReadFile, backendName, path, storage.ErrUnknown, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (b *Backend) OpenReader(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nativePath, err := b.nativePath(path, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.rejectSymlinkAncestors(nativePath, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Lstat(nativePath)
|
||||
if err != nil {
|
||||
return nil, b.translateError(storage.OpOpenReader, path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, storage.NewError(storage.OpOpenReader, backendName, path, storage.ErrUnsupported, nil)
|
||||
}
|
||||
file, err := os.Open(nativePath)
|
||||
if err != nil {
|
||||
return nil, b.translateError(storage.OpOpenReader, path, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFile(ctx context.Context, path string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
opts.Size = int64(len(data))
|
||||
opts.SizeKnown = true
|
||||
return b.WriteFrom(ctx, path, bytes.NewReader(data), opts)
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFrom(ctx context.Context, path string, r io.Reader, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
nativePath, err := b.nativePath(path, false)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
parent := filepath.Dir(nativePath)
|
||||
if err := b.rejectSymlinkAncestors(parent, true); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
if info, err := os.Lstat(nativePath); err == nil {
|
||||
if !opts.Overwrite {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, backendName, path, storage.ErrAlreadyExist, nil)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, backendName, path, storage.ErrConflict, nil)
|
||||
}
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, path, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, path, err)
|
||||
}
|
||||
temp, err := os.CreateTemp(parent, ".distributor-write-*")
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, path, err)
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
written, copyErr := io.Copy(temp, r)
|
||||
closeErr := temp.Close()
|
||||
if copyErr != nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, backendName, path, storage.ErrUnknown, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, backendName, path, storage.ErrUnknown, closeErr)
|
||||
}
|
||||
if opts.SizeKnown && written != opts.Size {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, backendName, path, storage.ErrConflict, fmt.Errorf("stream size %d does not match expected size %d", written, opts.Size))
|
||||
}
|
||||
if err := os.Rename(tempPath, nativePath); err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, path, err)
|
||||
}
|
||||
cleanup = false
|
||||
return b.Stat(ctx, path)
|
||||
}
|
||||
|
||||
func (b *Backend) Stat(ctx context.Context, path string) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
nativePath, err := b.nativePath(path, true)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
info, err := os.Lstat(nativePath)
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpStat, path, err)
|
||||
}
|
||||
return entryFromInfo(path, info), nil
|
||||
}
|
||||
|
||||
func (b *Backend) Walk(ctx context.Context, prefix string, opts storage.WalkOptions, fn storage.WalkFunc) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
nativePrefix, err := b.nativePath(prefix, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(nativePrefix)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpWalk, prefix, err)
|
||||
}
|
||||
visited := 0
|
||||
emit := func(entry storage.Entry) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Limit > 0 && visited >= opts.Limit {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
visited++
|
||||
if err := fn(entry); err != nil {
|
||||
if errors.Is(err, storage.ErrStopWalk) {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
return storage.NewError(storage.OpWalk, backendName, entry.Path, storage.ErrUnknown, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
if err := emit(entryFromInfo(prefix, info)); errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
walkErr := filepath.WalkDir(nativePrefix, func(nativePath string, dirEntry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpWalk, prefix, err)
|
||||
}
|
||||
if nativePath == nativePrefix {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(b.root, nativePath)
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpWalk, prefix, err)
|
||||
}
|
||||
logicalPath := filepath.ToSlash(relPath)
|
||||
if !opts.Recursive && filepath.Dir(nativePath) != nativePrefix {
|
||||
if dirEntry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpWalk, logicalPath, err)
|
||||
}
|
||||
return emit(entryFromInfo(logicalPath, info))
|
||||
})
|
||||
if errors.Is(walkErr, storage.ErrStopWalk) {
|
||||
return nil
|
||||
}
|
||||
return walkErr
|
||||
}
|
||||
|
||||
func (b *Backend) HasAny(ctx context.Context, prefix string) (bool, error) {
|
||||
found := false
|
||||
err := b.Walk(ctx, prefix, storage.WalkOptions{Recursive: false, Limit: 1}, func(storage.Entry) error {
|
||||
found = true
|
||||
return storage.ErrStopWalk
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, logicalPath := range targets {
|
||||
nativePath, err := b.nativePath(logicalPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nativePath == b.root {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, backendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
info, err := os.Lstat(nativePath)
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, backendName, logicalPath, storage.ErrUnsupported, nil)
|
||||
}
|
||||
if err := os.Remove(nativePath); err != nil {
|
||||
return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err)
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(filepath.Dir(nativePath))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeletePrefix(ctx context.Context, prefix string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
nativePrefix, err := b.nativePath(prefix, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.rejectSymlinkAncestors(nativePrefix, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if prefix == "" {
|
||||
entries, err := os.ReadDir(nativePrefix)
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpDeletePrefix, prefix, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
child := filepath.Join(nativePrefix, entry.Name())
|
||||
if err := os.RemoveAll(child); err != nil {
|
||||
return b.translateError(storage.OpDeletePrefix, entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Lstat(nativePrefix); err != nil {
|
||||
if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpDeletePrefix, prefix, err)
|
||||
}
|
||||
if err := os.RemoveAll(nativePrefix); err != nil {
|
||||
return b.translateError(storage.OpDeletePrefix, prefix, err)
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(filepath.Dir(nativePrefix))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) nativePath(logicalPath string, allowEmpty bool) (string, error) {
|
||||
if logicalPath == "" {
|
||||
if !allowEmpty {
|
||||
return "", storage.NewError(storage.OpValidatePath, backendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
return b.root, nil
|
||||
}
|
||||
if err := storage.ValidatePath(logicalPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
nativePath := filepath.Clean(filepath.Join(b.root, filepath.FromSlash(logicalPath)))
|
||||
rel, err := filepath.Rel(b.root, nativePath)
|
||||
if err != nil {
|
||||
return "", storage.NewError(storage.OpValidatePath, backendName, logicalPath, storage.ErrInvalidPath, err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", storage.NewError(storage.OpValidatePath, backendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
return nativePath, nil
|
||||
}
|
||||
|
||||
func (b *Backend) rejectSymlinkAncestors(nativePath string, includeFinal bool) error {
|
||||
rel, err := filepath.Rel(b.root, nativePath)
|
||||
if err != nil {
|
||||
return storage.NewError(storage.OpValidatePath, backendName, nativePath, storage.ErrInvalidPath, err)
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(rel, string(filepath.Separator))
|
||||
limit := len(segments)
|
||||
if !includeFinal {
|
||||
limit--
|
||||
}
|
||||
current := b.root
|
||||
for i := 0; i < limit; i++ {
|
||||
current = filepath.Join(current, segments[i])
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpStat, filepath.ToSlash(filepath.Join(segments[:i+1]...)), err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return storage.NewError(storage.OpStat, backendName, filepath.ToSlash(filepath.Join(segments[:i+1]...)), storage.ErrUnsupported, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) pruneEmptyParents(start string) {
|
||||
for current := start; current != b.root && strings.HasPrefix(current, b.root); current = filepath.Dir(current) {
|
||||
err := os.Remove(current)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Backend) translateError(op, path string, err error) error {
|
||||
kind := storage.ErrUnknown
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
kind = storage.ErrNotFound
|
||||
case errors.Is(err, fs.ErrExist):
|
||||
kind = storage.ErrAlreadyExist
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
kind = storage.ErrPermission
|
||||
}
|
||||
return storage.NewError(op, backendName, path, kind, err)
|
||||
}
|
||||
|
||||
func entryFromInfo(path string, info fs.FileInfo) storage.Entry {
|
||||
entryType := storage.EntryTypeOther
|
||||
switch {
|
||||
case info.Mode()&os.ModeSymlink != 0:
|
||||
entryType = storage.EntryTypeSymlink
|
||||
case info.Mode().IsRegular():
|
||||
entryType = storage.EntryTypeFile
|
||||
case info.IsDir():
|
||||
entryType = storage.EntryTypeDirectory
|
||||
}
|
||||
return storage.Entry{
|
||||
Path: path,
|
||||
Type: entryType,
|
||||
Size: info.Size(),
|
||||
}
|
||||
}
|
||||
261
internal/adapters/local/backend_test.go
Normal file
261
internal/adapters/local/backend_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func TestBackendRejectsTraversal(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
_, err := backend.ReadFile(context.Background(), "../outside")
|
||||
if !storage.IsInvalidPath(err) {
|
||||
t.Fatalf("ReadFile traversal error = %v, want invalid path", err)
|
||||
}
|
||||
_, err = backend.WriteFile(context.Background(), "/absolute", []byte("data"), storage.WriteOptions{})
|
||||
if !storage.IsInvalidPath(err) {
|
||||
t.Fatalf("WriteFile absolute path error = %v, want invalid path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendReadWriteAndStream(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
|
||||
entry, err := backend.WriteFile(context.Background(), "reports/report.md", []byte("hello"), storage.WriteOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if entry.Path != "reports/report.md" || entry.Type != storage.EntryTypeFile || entry.Size != 5 {
|
||||
t.Fatalf("entry = %#v, want written file metadata", entry)
|
||||
}
|
||||
|
||||
data, err := backend.ReadFile(context.Background(), "reports/report.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Fatalf("ReadFile() = %q, want hello", data)
|
||||
}
|
||||
|
||||
reader, err := backend.OpenReader(context.Background(), "reports/report.md")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenReader() error = %v", err)
|
||||
}
|
||||
streamed, err := io.ReadAll(reader)
|
||||
closeErr := reader.Close()
|
||||
if err != nil || closeErr != nil {
|
||||
t.Fatalf("read stream error = %v close = %v", err, closeErr)
|
||||
}
|
||||
if !bytes.Equal(streamed, data) {
|
||||
t.Fatalf("streamed = %q, want %q", streamed, data)
|
||||
}
|
||||
|
||||
_, err = backend.WriteFile(context.Background(), "reports/report.md", []byte("again"), storage.WriteOptions{})
|
||||
if !storage.IsAlreadyExists(err) {
|
||||
t.Fatalf("WriteFile without overwrite error = %v, want already exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendStatWalkAndList(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
mustWrite(t, backend, "b/two.txt", "2")
|
||||
mustWrite(t, backend, "a/one.txt", "1")
|
||||
|
||||
entry, err := backend.Stat(context.Background(), "a/one.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if entry.Type != storage.EntryTypeFile || entry.Size != 1 {
|
||||
t.Fatalf("entry = %#v, want file size 1", entry)
|
||||
}
|
||||
|
||||
entries, err := storage.List(context.Background(), backend, "", storage.WalkOptions{Recursive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
want := []string{"a", "a/one.txt", "b", "b/two.txt"}
|
||||
if !reflect.DeepEqual(paths, want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
|
||||
entries, err = storage.List(context.Background(), backend, "", storage.WalkOptions{Recursive: false})
|
||||
if err != nil {
|
||||
t.Fatalf("List nonrecursive error = %v", err)
|
||||
}
|
||||
paths = paths[:0]
|
||||
for _, entry := range entries {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
want = []string{"a", "b"}
|
||||
if !reflect.DeepEqual(paths, want) {
|
||||
t.Fatalf("nonrecursive paths = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendSymlinkReportingAndReadRejection(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
backend, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o600); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target.txt", filepath.Join(root, "link.txt")); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
entry, err := backend.Stat(context.Background(), "link.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if entry.Type != storage.EntryTypeSymlink {
|
||||
t.Fatalf("entry type = %s, want symlink", entry.Type)
|
||||
}
|
||||
_, err = backend.ReadFile(context.Background(), "link.txt")
|
||||
if !storage.IsUnsupported(err) {
|
||||
t.Fatalf("ReadFile symlink error = %v, want unsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendWriteFromSizeMismatchLeavesNoFinalFile(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
_, err := backend.WriteFrom(context.Background(), "out.txt", bytes.NewBufferString("short"), storage.WriteOptions{SizeKnown: true, Size: 99})
|
||||
if !storage.IsConflict(err) {
|
||||
t.Fatalf("WriteFrom size mismatch error = %v, want conflict", err)
|
||||
}
|
||||
_, err = backend.Stat(context.Background(), "out.txt")
|
||||
if !storage.IsNotFound(err) {
|
||||
t.Fatalf("Stat after failed write error = %v, want not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendManagedDeletion(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
mustWrite(t, backend, "bundle/report.html", "html")
|
||||
mustWrite(t, backend, "bundle/keep.txt", "keep")
|
||||
statePath, err := storage.StatePath("bundle")
|
||||
if err != nil {
|
||||
t.Fatalf("StatePath() error = %v", err)
|
||||
}
|
||||
mustWrite(t, backend, statePath, "{}")
|
||||
|
||||
err = backend.DeleteManagedBundle(context.Background(), "bundle", []string{"report.html"}, storage.DeleteOptions{PruneEmptyDirs: true})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteManagedBundle() error = %v", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) {
|
||||
t.Fatalf("managed output stat error = %v, want not found", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), statePath); !storage.IsNotFound(err) {
|
||||
t.Fatalf("state stat error = %v, want not found", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil {
|
||||
t.Fatalf("unlisted file stat error = %v", err)
|
||||
}
|
||||
if err := backend.DeleteManagedBundle(context.Background(), "bundle", []string{""}, storage.DeleteOptions{}); !storage.IsInvalidPath(err) {
|
||||
t.Fatalf("DeleteManagedBundle invalid output error = %v, want invalid path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendDeletePrefixStaysWithinPrefix(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
mustWrite(t, backend, "bundle/report.md", "report")
|
||||
mustWrite(t, backend, "bundle/nested/old.txt", "old")
|
||||
mustWrite(t, backend, "bundle-sibling/keep.txt", "keep")
|
||||
mustWrite(t, backend, "outside.txt", "outside")
|
||||
|
||||
if err := backend.DeletePrefix(context.Background(), "bundle", storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
t.Fatalf("DeletePrefix() error = %v", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), "bundle/report.md"); !storage.IsNotFound(err) {
|
||||
t.Fatalf("deleted file stat error = %v, want not found", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), "bundle/nested/old.txt"); !storage.IsNotFound(err) {
|
||||
t.Fatalf("deleted nested file stat error = %v, want not found", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), "bundle-sibling/keep.txt"); err != nil {
|
||||
t.Fatalf("sibling stat error = %v", err)
|
||||
}
|
||||
if _, err := backend.Stat(context.Background(), "outside.txt"); err != nil {
|
||||
t.Fatalf("outside stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendHasAny(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
found, err := backend.HasAny(context.Background(), "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny missing error = %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("HasAny missing = true, want false")
|
||||
}
|
||||
mustWrite(t, backend, "bundle/report.md", "report")
|
||||
found, err = backend.HasAny(context.Background(), "bundle")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny bundle error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("HasAny bundle = false, want true")
|
||||
}
|
||||
found, err = backend.HasAny(context.Background(), "bund")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny sibling prefix error = %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("HasAny prefix sibling = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendWalkStops(t *testing.T) {
|
||||
backend := newBackend(t)
|
||||
mustWrite(t, backend, "a.txt", "a")
|
||||
mustWrite(t, backend, "b.txt", "b")
|
||||
visited := 0
|
||||
err := backend.Walk(context.Background(), "", storage.WalkOptions{Recursive: true}, func(storage.Entry) error {
|
||||
visited++
|
||||
return storage.ErrStopWalk
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Walk() error = %v", err)
|
||||
}
|
||||
if visited != 1 {
|
||||
t.Fatalf("visited = %d, want 1", visited)
|
||||
}
|
||||
|
||||
errSentinel := errors.New("callback")
|
||||
err = backend.Walk(context.Background(), "", storage.WalkOptions{Recursive: true}, func(storage.Entry) error {
|
||||
return errSentinel
|
||||
})
|
||||
if !errors.Is(err, errSentinel) {
|
||||
t.Fatalf("Walk callback error = %v, want sentinel", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newBackend(t *testing.T) *Backend {
|
||||
t.Helper()
|
||||
backend, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, backend *Backend, path, data string) {
|
||||
t.Helper()
|
||||
if _, err := backend.WriteFile(context.Background(), path, []byte(data), storage.WriteOptions{}); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
485
internal/adapters/s3/backend.go
Normal file
485
internal/adapters/s3/backend.go
Normal file
@@ -0,0 +1,485 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awscfg "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
HeadObject(ctx context.Context, input *awss3.HeadObjectInput, optFns ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error)
|
||||
GetObject(ctx context.Context, input *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
|
||||
PutObject(ctx context.Context, input *awss3.PutObjectInput, optFns ...func(*awss3.Options)) (*awss3.PutObjectOutput, error)
|
||||
ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error)
|
||||
DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error)
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
client Client
|
||||
bucket string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func New(ctx context.Context, options Options) (*Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options, err := options.normalized()
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Prefix, storage.ErrInvalidPath, err)
|
||||
}
|
||||
loadOptions := []func(*awscfg.LoadOptions) error{
|
||||
awscfg.WithRegion(options.Region),
|
||||
}
|
||||
if options.AccessKeyID != "" {
|
||||
loadOptions = append(loadOptions, awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(options.AccessKeyID, options.SecretAccessKey, "")))
|
||||
}
|
||||
cfg, err := awscfg.LoadDefaultConfig(ctx, loadOptions...)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Endpoint, storage.ErrUnknown, err)
|
||||
}
|
||||
client := awss3.NewFromConfig(cfg, func(o *awss3.Options) {
|
||||
o.BaseEndpoint = aws.String(options.Endpoint)
|
||||
o.UsePathStyle = options.ForcePathStyle
|
||||
})
|
||||
return NewWithClient(client, options)
|
||||
}
|
||||
|
||||
func NewWithClient(client Client, options Options) (*Backend, error) {
|
||||
options, err := options.normalized()
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Prefix, storage.ErrInvalidPath, err)
|
||||
}
|
||||
if client == nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Bucket, storage.ErrInvalidPath, fmt.Errorf("client is required"))
|
||||
}
|
||||
return &Backend{
|
||||
client: client,
|
||||
bucket: options.Bucket,
|
||||
prefix: options.Prefix,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) ReadFile(ctx context.Context, logicalPath string) ([]byte, error) {
|
||||
reader, err := b.OpenReader(ctx, logicalPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpReadFile, BackendName, logicalPath, storage.ErrUnknown, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (b *Backend) OpenReader(ctx context.Context, logicalPath string) (io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := b.client.GetObject(ctx, &awss3.GetObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, b.translateError(storage.OpOpenReader, logicalPath, err)
|
||||
}
|
||||
return output.Body, nil
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFile(ctx context.Context, logicalPath string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
opts.Size = int64(len(data))
|
||||
opts.SizeKnown = true
|
||||
return b.WriteFrom(ctx, logicalPath, bytes.NewReader(data), opts)
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
if !opts.Overwrite {
|
||||
_, err := b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err == nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrAlreadyExist, nil)
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
}
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrUnknown, err)
|
||||
}
|
||||
if opts.SizeKnown && int64(len(data)) != opts.Size {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrConflict, fmt.Errorf("stream size %d does not match expected size %d", len(data), opts.Size))
|
||||
}
|
||||
contentType := opts.ContentType
|
||||
if contentType == "" {
|
||||
contentType = ContentType(logicalPath)
|
||||
}
|
||||
_, err = b.client.PutObject(ctx, &awss3.PutObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(data),
|
||||
ContentLength: aws.Int64(int64(len(data))),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
return storage.Entry{Path: logicalPath, Type: storage.EntryTypeFile, Size: int64(len(data))}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) Stat(ctx context.Context, logicalPath string) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
output, err := b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpStat, logicalPath, err)
|
||||
}
|
||||
size := int64(0)
|
||||
if output.ContentLength != nil {
|
||||
size = *output.ContentLength
|
||||
}
|
||||
return storage.Entry{Path: logicalPath, Type: storage.EntryTypeFile, Size: size}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) Walk(ctx context.Context, logicalPrefix string, opts storage.WalkOptions, fn storage.WalkFunc) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := storage.ValidatePrefix(logicalPrefix); err != nil {
|
||||
return err
|
||||
}
|
||||
visited := 0
|
||||
emit := func(entry storage.Entry) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Limit > 0 && visited >= opts.Limit {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
visited++
|
||||
if err := fn(entry); err != nil {
|
||||
if errors.Is(err, storage.ErrStopWalk) {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
return storage.NewError(storage.OpWalk, BackendName, entry.Path, storage.ErrUnknown, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if logicalPrefix != "" {
|
||||
entry, err := b.Stat(ctx, logicalPrefix)
|
||||
if err == nil {
|
||||
if err := emit(entry); errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Limit > 0 && visited >= opts.Limit {
|
||||
return nil
|
||||
}
|
||||
} else if !storage.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := b.walkObjects(ctx, logicalPrefix, opts, emit)
|
||||
if errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Backend) HasAny(ctx context.Context, logicalPrefix string) (bool, error) {
|
||||
found := false
|
||||
err := b.Walk(ctx, logicalPrefix, storage.WalkOptions{Recursive: false, Limit: 1}, func(storage.Entry) error {
|
||||
found = true
|
||||
return storage.ErrStopWalk
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := b.deleteObject(ctx, storage.OpDeleteManagedBundle, target, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeletePrefix(ctx context.Context, logicalPrefix string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := storage.ValidatePrefix(logicalPrefix); err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
if logicalPrefix != "" {
|
||||
key, err := b.objectKey(logicalPrefix, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err == nil {
|
||||
found = true
|
||||
if err := b.deleteObject(ctx, storage.OpDeletePrefix, logicalPrefix, storage.DeleteOptions{IgnoreMissing: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !isNotFound(err) {
|
||||
return b.translateError(storage.OpDeletePrefix, logicalPrefix, err)
|
||||
}
|
||||
}
|
||||
var entries []storage.Entry
|
||||
if err := b.walkObjects(ctx, logicalPrefix, storage.WalkOptions{Recursive: true}, func(entry storage.Entry) error {
|
||||
if entry.Type == storage.EntryTypeFile {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
found = true
|
||||
if err := b.deleteObject(ctx, storage.OpDeletePrefix, entry.Path, storage.DeleteOptions{IgnoreMissing: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !found && !opts.IgnoreMissing {
|
||||
return storage.NewError(storage.OpDeletePrefix, BackendName, logicalPrefix, storage.ErrNotFound, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) deleteObject(ctx context.Context, op, logicalPath string, opts storage.DeleteOptions) error {
|
||||
key, err := b.objectKey(logicalPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key == b.prefix {
|
||||
return storage.NewError(op, BackendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
if !opts.IgnoreMissing {
|
||||
_, err := b.client.HeadObject(ctx, &awss3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return b.translateError(op, logicalPath, err)
|
||||
}
|
||||
}
|
||||
_, err = b.client.DeleteObject(ctx, &awss3.DeleteObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && isNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(op, logicalPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) walkObjects(ctx context.Context, logicalPrefix string, opts storage.WalkOptions, emit func(storage.Entry) error) error {
|
||||
listPrefix, err := b.listPrefix(logicalPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delimiter := ""
|
||||
if !opts.Recursive {
|
||||
delimiter = "/"
|
||||
}
|
||||
var token *string
|
||||
for {
|
||||
output, err := b.client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Prefix: aws.String(listPrefix),
|
||||
Delimiter: aws.String(delimiter),
|
||||
ContinuationToken: token,
|
||||
})
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpWalk, logicalPrefix, err)
|
||||
}
|
||||
entries := entriesFromList(logicalPrefix, b.prefix, output)
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
|
||||
for _, entry := range entries {
|
||||
if entry.Path == "" {
|
||||
continue
|
||||
}
|
||||
if err := emit(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if output.IsTruncated == nil || !*output.IsTruncated {
|
||||
return nil
|
||||
}
|
||||
token = output.NextContinuationToken
|
||||
}
|
||||
}
|
||||
|
||||
func entriesFromList(logicalPrefix, rootPrefix string, output *awss3.ListObjectsV2Output) []storage.Entry {
|
||||
seen := make(map[string]storage.Entry)
|
||||
for _, object := range output.Contents {
|
||||
if object.Key == nil {
|
||||
continue
|
||||
}
|
||||
logicalPath := logicalPathFromKey(rootPrefix, *object.Key)
|
||||
if logicalPath == "" || logicalPath == logicalPrefix {
|
||||
continue
|
||||
}
|
||||
size := int64(0)
|
||||
if object.Size != nil {
|
||||
size = *object.Size
|
||||
}
|
||||
seen[logicalPath] = storage.Entry{Path: logicalPath, Type: storage.EntryTypeFile, Size: size}
|
||||
}
|
||||
for _, commonPrefix := range output.CommonPrefixes {
|
||||
if commonPrefix.Prefix == nil {
|
||||
continue
|
||||
}
|
||||
logicalPath := strings.TrimSuffix(logicalPathFromKey(rootPrefix, *commonPrefix.Prefix), "/")
|
||||
if logicalPath == "" || logicalPath == logicalPrefix {
|
||||
continue
|
||||
}
|
||||
seen[logicalPath] = storage.Entry{Path: logicalPath, Type: storage.EntryTypeDirectory}
|
||||
}
|
||||
entries := make([]storage.Entry, 0, len(seen))
|
||||
for _, entry := range seen {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (b *Backend) objectKey(logicalPath string, allowEmpty bool) (string, error) {
|
||||
if logicalPath == "" {
|
||||
if !allowEmpty {
|
||||
return "", storage.NewError(storage.OpValidatePath, BackendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
return b.prefix, nil
|
||||
}
|
||||
if err := storage.ValidatePath(logicalPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b.prefix == "" {
|
||||
return logicalPath, nil
|
||||
}
|
||||
return b.prefix + "/" + logicalPath, nil
|
||||
}
|
||||
|
||||
func (b *Backend) listPrefix(logicalPrefix string) (string, error) {
|
||||
key, err := b.objectKey(logicalPrefix, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key != "" {
|
||||
key = strings.TrimSuffix(key, "/") + "/"
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func logicalPathFromKey(rootPrefix, key string) string {
|
||||
if rootPrefix == "" {
|
||||
return key
|
||||
}
|
||||
if key == rootPrefix {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(key, rootPrefix+"/")
|
||||
}
|
||||
|
||||
func ContentType(logicalPath string) string {
|
||||
switch strings.ToLower(path.Ext(logicalPath)) {
|
||||
case ".md":
|
||||
return "text/markdown; charset=utf-8"
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".txt":
|
||||
return "text/plain; charset=utf-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
var notFound *types.NotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return true
|
||||
}
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "NotFound", "NoSuchKey", "404":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Backend) translateError(op, logicalPath string, err error) error {
|
||||
kind := storage.ErrUnknown
|
||||
if isNotFound(err) {
|
||||
kind = storage.ErrNotFound
|
||||
} else {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
case "AccessDenied", "InvalidAccessKeyId", "SignatureDoesNotMatch":
|
||||
kind = storage.ErrPermission
|
||||
case "SlowDown", "RequestTimeout", "ServiceUnavailable", "InternalError":
|
||||
kind = storage.ErrTemporary
|
||||
case "InvalidBucketName", "NoSuchBucket":
|
||||
kind = storage.ErrInvalidPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return storage.NewError(op, BackendName, logicalPath, kind, err)
|
||||
}
|
||||
442
internal/adapters/s3/backend_test.go
Normal file
442
internal/adapters/s3/backend_test.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
func TestKeyAndPrefixNormalization(t *testing.T) {
|
||||
backend := newTestBackend(t, "root/prefix", nil)
|
||||
key, err := backend.objectKey("bundle/report.md", false)
|
||||
if err != nil {
|
||||
t.Fatalf("objectKey() error = %v", err)
|
||||
}
|
||||
if got, want := key, "root/prefix/bundle/report.md"; got != want {
|
||||
t.Fatalf("objectKey() = %q, want %q", got, want)
|
||||
}
|
||||
listPrefix, err := backend.listPrefix("bundle")
|
||||
if err != nil {
|
||||
t.Fatalf("listPrefix() error = %v", err)
|
||||
}
|
||||
if got, want := listPrefix, "root/prefix/bundle/"; got != want {
|
||||
t.Fatalf("listPrefix() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
backend := newTestBackend(t, "", nil)
|
||||
for _, logicalPath := range []string{"/absolute", "../escape", "a/../b", `a\b`} {
|
||||
t.Run(logicalPath, func(t *testing.T) {
|
||||
if _, err := backend.objectKey(logicalPath, false); err == nil || !storage.IsInvalidPath(err) {
|
||||
t.Fatalf("objectKey() error = %v, want invalid path", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"report.md": "text/markdown; charset=utf-8",
|
||||
"report.html": "text/html; charset=utf-8",
|
||||
"state.json": "application/json",
|
||||
"summary.txt": "text/plain; charset=utf-8",
|
||||
"data.bin": "application/octet-stream",
|
||||
}
|
||||
for path, want := range tests {
|
||||
if got := ContentType(path); got != want {
|
||||
t.Fatalf("ContentType(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatRequiresExactObject(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{"root/dir/file.txt": "data"})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
_, err := backend.Stat(context.Background(), "dir")
|
||||
if err == nil || !storage.IsNotFound(err) {
|
||||
t.Fatalf("Stat() error = %v, want not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkIncludesExactObjectAndDescendants(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"root/bundle": "marker",
|
||||
"root/bundle/report.md": "report",
|
||||
})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
entries, err := storage.List(context.Background(), backend, "bundle", storage.WalkOptions{Recursive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
paths := entryPaths(entries)
|
||||
if got, want := paths, []string{"bundle", "bundle/report.md"}; !equalStrings(got, want) {
|
||||
t.Fatalf("paths = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkExactObjectHonorsLimitBeforeListingDescendants(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"root/bundle": "marker",
|
||||
"root/bundle/report.md": "report",
|
||||
})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
var entries []storage.Entry
|
||||
err := backend.Walk(context.Background(), "bundle", storage.WalkOptions{Recursive: true, Limit: 1}, func(entry storage.Entry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Walk() error = %v", err)
|
||||
}
|
||||
paths := entryPaths(entries)
|
||||
if got, want := paths, []string{"bundle"}; !equalStrings(got, want) {
|
||||
t.Fatalf("paths = %v, want %v", got, want)
|
||||
}
|
||||
if len(client.tokens) != 0 {
|
||||
t.Fatalf("list calls = %d, want none", len(client.tokens))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAnyWithExactObjectStopsBeforeListingDescendants(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"root/bundle": "marker",
|
||||
"root/bundle/report.md": "report",
|
||||
})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
found, err := backend.HasAny(context.Background(), "bundle")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("HasAny() = false, want true")
|
||||
}
|
||||
if len(client.tokens) != 0 {
|
||||
t.Fatalf("list calls = %d, want none", len(client.tokens))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFromChecksOverwriteBeforePut(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{"root/report.md": "old"})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
_, err := backend.WriteFile(context.Background(), "report.md", []byte("new"), storage.WriteOptions{})
|
||||
if err == nil || !storage.IsAlreadyExists(err) {
|
||||
t.Fatalf("WriteFile() error = %v, want already exists", err)
|
||||
}
|
||||
if len(client.putKeys) != 0 {
|
||||
t.Fatalf("put keys = %v, want none", client.putKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFromPutsNewObjectWithContentType(t *testing.T) {
|
||||
client := newFakeClient(nil)
|
||||
backend := newTestBackend(t, "root", client)
|
||||
entry, err := backend.WriteFile(context.Background(), "report.html", []byte("<p>ok</p>"), storage.WriteOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if entry.Path != "report.html" || entry.Size != 9 || entry.Type != storage.EntryTypeFile {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
if got, want := client.objects["root/report.html"], "<p>ok</p>"; got != want {
|
||||
t.Fatalf("object = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := client.contentTypes["root/report.html"], "text/html; charset=utf-8"; got != want {
|
||||
t.Fatalf("content type = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkUsesPagination(t *testing.T) {
|
||||
client := newFakeClient(nil)
|
||||
client.listPages = []awss3.ListObjectsV2Output{
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/a.txt"), Size: aws.Int64(1)}},
|
||||
IsTruncated: aws.Bool(true),
|
||||
NextContinuationToken: aws.String("next"),
|
||||
},
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/b.txt"), Size: aws.Int64(2)}},
|
||||
IsTruncated: aws.Bool(false),
|
||||
},
|
||||
}
|
||||
backend := newTestBackend(t, "root", client)
|
||||
entries, err := storage.List(context.Background(), backend, "", storage.WalkOptions{Recursive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
paths := entryPaths(entries)
|
||||
if got, want := paths, []string{"a.txt", "b.txt"}; !equalStrings(got, want) {
|
||||
t.Fatalf("paths = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := client.tokens, []string{"", "next"}; !equalStrings(got, want) {
|
||||
t.Fatalf("tokens = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAnyStopsAfterFirstPage(t *testing.T) {
|
||||
client := newFakeClient(nil)
|
||||
client.listPages = []awss3.ListObjectsV2Output{
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/a.txt"), Size: aws.Int64(1)}},
|
||||
IsTruncated: aws.Bool(true),
|
||||
NextContinuationToken: aws.String("next"),
|
||||
},
|
||||
{
|
||||
Contents: []types.Object{{Key: aws.String("root/b.txt"), Size: aws.Int64(2)}},
|
||||
IsTruncated: aws.Bool(false),
|
||||
},
|
||||
}
|
||||
backend := newTestBackend(t, "root", client)
|
||||
found, err := backend.HasAny(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("HasAny() = false, want true")
|
||||
}
|
||||
if got, want := len(client.tokens), 1; got != want {
|
||||
t.Fatalf("list calls = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkNonRecursiveUsesPrefixBoundary(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"base/dir/file.txt": "nested",
|
||||
"base/file.txt": "file",
|
||||
"baseball/file.txt": "wrong",
|
||||
})
|
||||
backend := newTestBackend(t, "base", client)
|
||||
entries, err := storage.List(context.Background(), backend, "", storage.WalkOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
paths := entryPaths(entries)
|
||||
if got, want := paths, []string{"dir", "file.txt"}; !equalStrings(got, want) {
|
||||
t.Fatalf("paths = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteManagedBundleDeletesOnlyManagedTargets(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"root/report.md": "report",
|
||||
"root/.distributor.json": "state",
|
||||
"root/keep.txt": "keep",
|
||||
})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
err := backend.DeleteManagedBundle(context.Background(), "", []string{"report.md"}, storage.DeleteOptions{IgnoreMissing: true})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteManagedBundle() error = %v", err)
|
||||
}
|
||||
if _, ok := client.objects["root/report.md"]; ok {
|
||||
t.Fatal("managed output still exists")
|
||||
}
|
||||
if _, ok := client.objects["root/.distributor.json"]; ok {
|
||||
t.Fatal("state file still exists")
|
||||
}
|
||||
if _, ok := client.objects["root/keep.txt"]; !ok {
|
||||
t.Fatal("unmanaged object was deleted")
|
||||
}
|
||||
if got, want := sortedStrings(client.deleteKeys), []string{"root/.distributor.json", "root/report.md"}; !equalStrings(got, want) {
|
||||
t.Fatalf("deleted keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePrefixStaysWithinPrefix(t *testing.T) {
|
||||
client := newFakeClient(map[string]string{
|
||||
"root/bundle/report.md": "report",
|
||||
"root/bundle/nested/old.txt": "old",
|
||||
"root/bundle-sibling/keep.txt": "keep",
|
||||
"root/outside.txt": "outside",
|
||||
"other-root/bundle/report.md": "other",
|
||||
"root/.distributor-prefix-marker": "marker",
|
||||
})
|
||||
backend := newTestBackend(t, "root", client)
|
||||
if err := backend.DeletePrefix(context.Background(), "bundle", storage.DeleteOptions{IgnoreMissing: true}); err != nil {
|
||||
t.Fatalf("DeletePrefix() error = %v", err)
|
||||
}
|
||||
for _, deleted := range []string{"root/bundle/report.md", "root/bundle/nested/old.txt"} {
|
||||
if _, ok := client.objects[deleted]; ok {
|
||||
t.Fatalf("%s still exists", deleted)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{"root/bundle-sibling/keep.txt", "root/outside.txt", "other-root/bundle/report.md", "root/.distributor-prefix-marker"} {
|
||||
if _, ok := client.objects[kept]; !ok {
|
||||
t.Fatalf("%s was deleted", kept)
|
||||
}
|
||||
}
|
||||
if got, want := sortedStrings(client.deleteKeys), []string{"root/bundle/nested/old.txt", "root/bundle/report.md"}; !equalStrings(got, want) {
|
||||
t.Fatalf("deleted keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBackend(t *testing.T, prefix string, client *fakeClient) *Backend {
|
||||
t.Helper()
|
||||
if client == nil {
|
||||
client = newFakeClient(nil)
|
||||
}
|
||||
backend, err := NewWithClient(client, Options{
|
||||
Endpoint: "https://s3.example.com",
|
||||
Bucket: "reports",
|
||||
Prefix: prefix,
|
||||
Region: DefaultRegion,
|
||||
ForcePathStyle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithClient() error = %v", err)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
objects map[string]string
|
||||
contentTypes map[string]string
|
||||
listPages []awss3.ListObjectsV2Output
|
||||
tokens []string
|
||||
putKeys []string
|
||||
deleteKeys []string
|
||||
}
|
||||
|
||||
func newFakeClient(objects map[string]string) *fakeClient {
|
||||
copied := make(map[string]string)
|
||||
for key, value := range objects {
|
||||
copied[key] = value
|
||||
}
|
||||
return &fakeClient{
|
||||
objects: copied,
|
||||
contentTypes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeClient) HeadObject(ctx context.Context, input *awss3.HeadObjectInput, optFns ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, ok := c.objects[aws.ToString(input.Key)]
|
||||
if !ok {
|
||||
return nil, &types.NotFound{}
|
||||
}
|
||||
return &awss3.HeadObjectOutput{ContentLength: aws.Int64(int64(len(value)))}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) GetObject(ctx context.Context, input *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, ok := c.objects[aws.ToString(input.Key)]
|
||||
if !ok {
|
||||
return nil, &types.NotFound{}
|
||||
}
|
||||
return &awss3.GetObjectOutput{
|
||||
Body: io.NopCloser(stringsReader(value)),
|
||||
ContentLength: aws.Int64(int64(len(value))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) PutObject(ctx context.Context, input *awss3.PutObjectInput, optFns ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := io.ReadAll(input.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := aws.ToString(input.Key)
|
||||
c.objects[key] = string(data)
|
||||
c.contentTypes[key] = aws.ToString(input.ContentType)
|
||||
c.putKeys = append(c.putKeys, key)
|
||||
return &awss3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.tokens = append(c.tokens, aws.ToString(input.ContinuationToken))
|
||||
if len(c.listPages) > 0 {
|
||||
index := len(c.tokens) - 1
|
||||
if index >= len(c.listPages) {
|
||||
return &awss3.ListObjectsV2Output{IsTruncated: aws.Bool(false)}, nil
|
||||
}
|
||||
page := c.listPages[index]
|
||||
return &page, nil
|
||||
}
|
||||
return c.dynamicList(input), nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := aws.ToString(input.Key)
|
||||
delete(c.objects, key)
|
||||
c.deleteKeys = append(c.deleteKeys, key)
|
||||
return &awss3.DeleteObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) dynamicList(input *awss3.ListObjectsV2Input) *awss3.ListObjectsV2Output {
|
||||
prefix := aws.ToString(input.Prefix)
|
||||
delimiter := aws.ToString(input.Delimiter)
|
||||
var contents []types.Object
|
||||
commonPrefixes := make(map[string]struct{})
|
||||
for key, value := range c.objects {
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
remainder := strings.TrimPrefix(key, prefix)
|
||||
if delimiter != "" {
|
||||
if index := strings.Index(remainder, delimiter); index >= 0 {
|
||||
commonPrefixes[prefix+remainder[:index+1]] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
contents = append(contents, types.Object{Key: aws.String(key), Size: aws.Int64(int64(len(value)))})
|
||||
}
|
||||
sort.Slice(contents, func(i, j int) bool { return aws.ToString(contents[i].Key) < aws.ToString(contents[j].Key) })
|
||||
prefixes := make([]types.CommonPrefix, 0, len(commonPrefixes))
|
||||
for prefix := range commonPrefixes {
|
||||
prefixes = append(prefixes, types.CommonPrefix{Prefix: aws.String(prefix)})
|
||||
}
|
||||
sort.Slice(prefixes, func(i, j int) bool { return aws.ToString(prefixes[i].Prefix) < aws.ToString(prefixes[j].Prefix) })
|
||||
return &awss3.ListObjectsV2Output{
|
||||
Contents: contents,
|
||||
CommonPrefixes: prefixes,
|
||||
IsTruncated: aws.Bool(false),
|
||||
}
|
||||
}
|
||||
|
||||
func stringsReader(value string) io.Reader {
|
||||
return strings.NewReader(value)
|
||||
}
|
||||
|
||||
func entryPaths(entries []storage.Entry) []string {
|
||||
paths := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func sortedStrings(values []string) []string {
|
||||
copied := append([]string(nil), values...)
|
||||
sort.Strings(copied)
|
||||
return copied
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for index := range a {
|
||||
if a[index] != b[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
39
internal/adapters/s3/integration_test.go
Normal file
39
internal/adapters/s3/integration_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntegrationS3BackendHasAny(t *testing.T) {
|
||||
endpoint := os.Getenv("DISTRIBUTOR_TEST_S3_ENDPOINT")
|
||||
bucket := os.Getenv("DISTRIBUTOR_TEST_S3_BUCKET")
|
||||
if endpoint == "" || bucket == "" {
|
||||
t.Skip("DISTRIBUTOR_TEST_S3_ENDPOINT and DISTRIBUTOR_TEST_S3_BUCKET are not set")
|
||||
}
|
||||
forcePathStyle := true
|
||||
if raw := os.Getenv("DISTRIBUTOR_TEST_S3_FORCE_PATH_STYLE"); raw != "" {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DISTRIBUTOR_TEST_S3_FORCE_PATH_STYLE: %v", err)
|
||||
}
|
||||
forcePathStyle = parsed
|
||||
}
|
||||
backend, err := New(context.Background(), Options{
|
||||
Endpoint: endpoint,
|
||||
Bucket: bucket,
|
||||
Prefix: os.Getenv("DISTRIBUTOR_TEST_S3_PREFIX"),
|
||||
Region: os.Getenv("DISTRIBUTOR_TEST_S3_REGION"),
|
||||
ForcePathStyle: forcePathStyle,
|
||||
AccessKeyID: os.Getenv("DISTRIBUTOR_TEST_S3_ACCESS_KEY_ID"),
|
||||
SecretAccessKey: os.Getenv("DISTRIBUTOR_TEST_S3_SECRET_ACCESS_KEY"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if _, err := backend.HasAny(context.Background(), ""); err != nil {
|
||||
t.Fatalf("HasAny(root) error = %v", err)
|
||||
}
|
||||
}
|
||||
42
internal/adapters/s3/options.go
Normal file
42
internal/adapters/s3/options.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
const BackendName = "s3"
|
||||
|
||||
const DefaultRegion = "us-east-1"
|
||||
|
||||
type Options struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
Prefix string
|
||||
Region string
|
||||
ForcePathStyle bool
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
}
|
||||
|
||||
func (o Options) normalized() (Options, error) {
|
||||
if o.Endpoint == "" {
|
||||
return Options{}, fmt.Errorf("endpoint is required")
|
||||
}
|
||||
if o.Bucket == "" {
|
||||
return Options{}, fmt.Errorf("bucket is required")
|
||||
}
|
||||
if o.Region == "" {
|
||||
o.Region = DefaultRegion
|
||||
}
|
||||
o.Prefix = strings.Trim(o.Prefix, "/")
|
||||
if err := storage.ValidatePrefix(o.Prefix); err != nil {
|
||||
return Options{}, fmt.Errorf("prefix: %w", err)
|
||||
}
|
||||
if (o.AccessKeyID == "") != (o.SecretAccessKey == "") {
|
||||
return Options{}, fmt.Errorf("access key id and secret access key must be configured together")
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
61
internal/adapters/ssh/auth.go
Normal file
61
internal/adapters/ssh/auth.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
type agentDialer func(network, address string) (net.Conn, error)
|
||||
|
||||
func authMethods(keyFile string) ([]cryptossh.AuthMethod, func(), error) {
|
||||
return authMethodsWithAgent(os.Getenv("SSH_AUTH_SOCK"), net.Dial, keyFile)
|
||||
}
|
||||
|
||||
func authMethodsWithAgent(agentSocket string, dial agentDialer, keyFile string) ([]cryptossh.AuthMethod, func(), error) {
|
||||
var methods []cryptossh.AuthMethod
|
||||
var closers []io.Closer
|
||||
if agentSocket != "" {
|
||||
methods = append(methods, cryptossh.PublicKeysCallback(func() ([]cryptossh.Signer, error) {
|
||||
conn, err := dial("unix", agentSocket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closers = append(closers, conn)
|
||||
return agent.NewClient(conn).Signers()
|
||||
}))
|
||||
}
|
||||
if keyFile != "" {
|
||||
signer, err := signerFromKeyFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
methods = append(methods, cryptossh.PublicKeys(signer))
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
return nil, nil, fmt.Errorf("no SSH auth methods configured; set SSH_AUTH_SOCK or ssh_key_file")
|
||||
}
|
||||
return methods, func() { closeAll(closers) }, nil
|
||||
}
|
||||
|
||||
func signerFromKeyFile(path string) (cryptossh.Signer, error) {
|
||||
key, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ssh_key_file %q: %w", path, err)
|
||||
}
|
||||
signer, err := cryptossh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ssh_key_file %q: %w", path, err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func closeAll(closers []io.Closer) {
|
||||
for _, closer := range closers {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
59
internal/adapters/ssh/auth_test.go
Normal file
59
internal/adapters/ssh/auth_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthMethodsPreferAgentBeforeKeyFile(t *testing.T) {
|
||||
keyFile := writePrivateKey(t)
|
||||
methods, cleanup, err := authMethodsWithAgent("/tmp/ssh-agent.sock", nil, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("authMethodsWithAgent() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
if got, want := len(methods), 2; got != want {
|
||||
t.Fatalf("auth method count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsLoadsKeyFile(t *testing.T) {
|
||||
keyFile := writePrivateKey(t)
|
||||
methods, cleanup, err := authMethodsWithAgent("", nil, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("authMethodsWithAgent() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
if got, want := len(methods), 1; got != want {
|
||||
t.Fatalf("auth method count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsRejectsMissingAuth(t *testing.T) {
|
||||
_, _, err := authMethodsWithAgent("", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("authMethodsWithAgent() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func writePrivateKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
data := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "id_rsa")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write private key: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
521
internal/adapters/ssh/backend.go
Normal file
521
internal/adapters/ssh/backend.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"github.com/pkg/sftp"
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
client *sftp.Client
|
||||
sshClient *cryptossh.Client
|
||||
root string
|
||||
}
|
||||
|
||||
func New(ctx context.Context, options Options) (*Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options, err := options.normalized()
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.Root, storage.ErrInvalidPath, err)
|
||||
}
|
||||
hostKeyCallback, err := hostKeyCallback(options)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.KnownHosts, storage.ErrInvalidPath, err)
|
||||
}
|
||||
auth, cleanupAuth, err := authMethods(options.KeyFile)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.KeyFile, storage.ErrPermission, err)
|
||||
}
|
||||
defer cleanupAuth()
|
||||
|
||||
sshClient, err := cryptossh.Dial("tcp", options.address(), &cryptossh.ClientConfig{
|
||||
User: options.User,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.address(), storage.ErrUnknown, err)
|
||||
}
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
_ = sshClient.Close()
|
||||
return nil, storage.NewError(storage.OpOpenBackend, BackendName, options.address(), storage.ErrUnknown, err)
|
||||
}
|
||||
return &Backend{client: client, sshClient: sshClient, root: options.Root}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) Close() error {
|
||||
var err error
|
||||
if b.client != nil {
|
||||
err = b.client.Close()
|
||||
}
|
||||
if b.sshClient != nil {
|
||||
if closeErr := b.sshClient.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Backend) ReadFile(ctx context.Context, logicalPath string) ([]byte, error) {
|
||||
reader, err := b.OpenReader(ctx, logicalPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, storage.NewError(storage.OpReadFile, BackendName, logicalPath, storage.ErrUnknown, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (b *Backend) OpenReader(ctx context.Context, logicalPath string) (io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nativePath, err := b.nativePath(logicalPath, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.rejectSymlinkAncestors(ctx, logicalPath, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := b.client.Lstat(nativePath)
|
||||
if err != nil {
|
||||
return nil, b.translateError(storage.OpOpenReader, logicalPath, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, storage.NewError(storage.OpOpenReader, BackendName, logicalPath, storage.ErrUnsupported, nil)
|
||||
}
|
||||
file, err := b.client.Open(nativePath)
|
||||
if err != nil {
|
||||
return nil, b.translateError(storage.OpOpenReader, logicalPath, err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFile(ctx context.Context, logicalPath string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
opts.Size = int64(len(data))
|
||||
opts.SizeKnown = true
|
||||
return b.WriteFrom(ctx, logicalPath, bytes.NewReader(data), opts)
|
||||
}
|
||||
|
||||
func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
nativePath, err := b.nativePath(logicalPath, false)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
if err := b.rejectSymlinkAncestors(ctx, parentOf(logicalPath), true); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
if info, err := b.client.Lstat(nativePath); err == nil {
|
||||
if !opts.Overwrite {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrAlreadyExist, nil)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrConflict, nil)
|
||||
}
|
||||
} else if !isNotExist(err) {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
|
||||
parentNative := path.Dir(nativePath)
|
||||
if err := b.client.MkdirAll(parentNative); err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
|
||||
writePath := nativePath
|
||||
if opts.PreferAtomic {
|
||||
writePath = path.Join(parentNative, fmt.Sprintf(".distributor-write-%d", time.Now().UnixNano()))
|
||||
}
|
||||
file, err := b.client.Create(writePath)
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
cleanup := opts.PreferAtomic
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = b.client.Remove(writePath)
|
||||
}
|
||||
}()
|
||||
|
||||
written, copyErr := io.Copy(file, r)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrUnknown, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrUnknown, closeErr)
|
||||
}
|
||||
if opts.SizeKnown && written != opts.Size {
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrConflict, fmt.Errorf("stream size %d does not match expected size %d", written, opts.Size))
|
||||
}
|
||||
if opts.PreferAtomic {
|
||||
if err := b.client.Rename(writePath, nativePath); err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
cleanup = false
|
||||
}
|
||||
return b.Stat(ctx, logicalPath)
|
||||
}
|
||||
|
||||
func (b *Backend) Stat(ctx context.Context, logicalPath string) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
nativePath, err := b.nativePath(logicalPath, true)
|
||||
if err != nil {
|
||||
return storage.Entry{}, err
|
||||
}
|
||||
info, err := b.client.Lstat(nativePath)
|
||||
if err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpStat, logicalPath, err)
|
||||
}
|
||||
return entryFromInfo(logicalPath, info), nil
|
||||
}
|
||||
|
||||
func (b *Backend) Walk(ctx context.Context, prefix string, opts storage.WalkOptions, fn storage.WalkFunc) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
nativePrefix, err := b.nativePath(prefix, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := b.client.Lstat(nativePrefix)
|
||||
if err != nil {
|
||||
if isNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpWalk, prefix, err)
|
||||
}
|
||||
|
||||
visited := 0
|
||||
emit := func(entry storage.Entry) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Limit > 0 && visited >= opts.Limit {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
visited++
|
||||
if err := fn(entry); err != nil {
|
||||
if errors.Is(err, storage.ErrStopWalk) {
|
||||
return storage.ErrStopWalk
|
||||
}
|
||||
return storage.NewError(storage.OpWalk, BackendName, entry.Path, storage.ErrUnknown, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
if err := emit(entryFromInfo(prefix, info)); errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := b.walkDirectory(ctx, prefix, nativePrefix, opts, emit); errors.Is(err, storage.ErrStopWalk) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) HasAny(ctx context.Context, prefix string) (bool, error) {
|
||||
found := false
|
||||
err := b.Walk(ctx, prefix, storage.WalkOptions{Recursive: false, Limit: 1}, func(storage.Entry) error {
|
||||
found = true
|
||||
return storage.ErrStopWalk
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
nativePath, err := b.nativePath(target, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nativePath == b.root {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, BackendName, target, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
info, err := b.client.Lstat(nativePath)
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && isNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return b.translateError(storage.OpDeleteManagedBundle, target, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, BackendName, target, storage.ErrUnsupported, nil)
|
||||
}
|
||||
if err := b.client.Remove(nativePath); err != nil {
|
||||
return b.translateError(storage.OpDeleteManagedBundle, target, err)
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(parentOf(target))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) DeletePrefix(ctx context.Context, prefix string, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := storage.ValidatePrefix(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
var entries []storage.Entry
|
||||
if prefix != "" {
|
||||
entry, err := b.Stat(ctx, prefix)
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && storage.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if entry.Type != storage.EntryTypeDirectory {
|
||||
return b.deleteEntry(ctx, entry, opts)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := b.Walk(ctx, prefix, storage.WalkOptions{Recursive: true}, func(entry storage.Entry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if prefix != "" && len(entries) == 1 {
|
||||
if err := b.deleteEntry(ctx, entries[0], opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(parentOf(prefix))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return strings.Count(entries[i].Path, "/") > strings.Count(entries[j].Path, "/")
|
||||
})
|
||||
for _, entry := range entries {
|
||||
if entry.Path == "" {
|
||||
continue
|
||||
}
|
||||
if err := b.deleteEntry(ctx, entry, storage.DeleteOptions{IgnoreMissing: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(parentOf(prefix))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) deleteEntry(ctx context.Context, entry storage.Entry, opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
nativePath, err := b.nativePath(entry.Path, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var removeErr error
|
||||
if entry.Type == storage.EntryTypeDirectory {
|
||||
removeErr = b.client.RemoveDirectory(nativePath)
|
||||
} else {
|
||||
removeErr = b.client.Remove(nativePath)
|
||||
}
|
||||
if removeErr != nil {
|
||||
if opts.IgnoreMissing && isNotExist(removeErr) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpDeletePrefix, entry.Path, removeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) walkDirectory(ctx context.Context, logicalPrefix, nativePrefix string, opts storage.WalkOptions, emit func(storage.Entry) error) error {
|
||||
entries, err := b.client.ReadDir(nativePrefix)
|
||||
if err != nil {
|
||||
return b.translateError(storage.OpWalk, logicalPrefix, err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, info := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
logicalPath := info.Name()
|
||||
if logicalPrefix != "" {
|
||||
logicalPath = logicalPrefix + "/" + info.Name()
|
||||
}
|
||||
if err := emit(entryFromInfo(logicalPath, info)); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Recursive && info.IsDir() {
|
||||
if err := b.walkDirectory(ctx, logicalPath, path.Join(nativePrefix, info.Name()), opts, emit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) nativePath(logicalPath string, allowEmpty bool) (string, error) {
|
||||
if logicalPath == "" {
|
||||
if !allowEmpty {
|
||||
return "", storage.NewError(storage.OpValidatePath, BackendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
return b.root, nil
|
||||
}
|
||||
if err := storage.ValidatePath(logicalPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
nativePath := path.Clean(path.Join(b.root, logicalPath))
|
||||
if !withinRoot(b.root, nativePath) {
|
||||
return "", storage.NewError(storage.OpValidatePath, BackendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
return nativePath, nil
|
||||
}
|
||||
|
||||
func (b *Backend) rejectSymlinkAncestors(ctx context.Context, logicalPath string, includeFinal bool) error {
|
||||
if logicalPath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := storage.ValidatePath(logicalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
segments := strings.Split(logicalPath, "/")
|
||||
limit := len(segments)
|
||||
if !includeFinal {
|
||||
limit--
|
||||
}
|
||||
current := ""
|
||||
for index := 0; index < limit; index++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if current == "" {
|
||||
current = segments[index]
|
||||
} else {
|
||||
current += "/" + segments[index]
|
||||
}
|
||||
nativePath, err := b.nativePath(current, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := b.client.Lstat(nativePath)
|
||||
if err != nil {
|
||||
if isNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return b.translateError(storage.OpStat, current, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return storage.NewError(storage.OpStat, BackendName, current, storage.ErrUnsupported, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) pruneEmptyParents(logicalPath string) {
|
||||
for logicalPath != "" {
|
||||
nativePath, err := b.nativePath(logicalPath, false)
|
||||
if err != nil || nativePath == b.root {
|
||||
return
|
||||
}
|
||||
if err := b.client.RemoveDirectory(nativePath); err != nil {
|
||||
return
|
||||
}
|
||||
logicalPath = parentOf(logicalPath)
|
||||
}
|
||||
}
|
||||
|
||||
func withinRoot(root, candidate string) bool {
|
||||
if candidate == root {
|
||||
return true
|
||||
}
|
||||
if root == "/" {
|
||||
return strings.HasPrefix(candidate, "/")
|
||||
}
|
||||
return strings.HasPrefix(candidate, strings.TrimSuffix(root, "/")+"/")
|
||||
}
|
||||
|
||||
func parentOf(logicalPath string) string {
|
||||
index := strings.LastIndex(logicalPath, "/")
|
||||
if index == -1 {
|
||||
return ""
|
||||
}
|
||||
return logicalPath[:index]
|
||||
}
|
||||
|
||||
func isNotExist(err error) bool {
|
||||
return errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) || errors.Is(err, sftp.ErrSSHFxNoSuchFile)
|
||||
}
|
||||
|
||||
func (b *Backend) translateError(op, logicalPath string, err error) error {
|
||||
kind := storage.ErrUnknown
|
||||
switch {
|
||||
case isNotExist(err):
|
||||
kind = storage.ErrNotFound
|
||||
case errors.Is(err, fs.ErrExist), errors.Is(err, os.ErrExist):
|
||||
kind = storage.ErrAlreadyExist
|
||||
case errors.Is(err, fs.ErrPermission), errors.Is(err, os.ErrPermission), errors.Is(err, sftp.ErrSSHFxPermissionDenied):
|
||||
kind = storage.ErrPermission
|
||||
case errors.Is(err, sftp.ErrSSHFxOpUnsupported):
|
||||
kind = storage.ErrUnsupported
|
||||
case errors.Is(err, sftp.ErrSSHFxNoConnection), errors.Is(err, sftp.ErrSSHFxConnectionLost):
|
||||
kind = storage.ErrTemporary
|
||||
}
|
||||
return storage.NewError(op, BackendName, logicalPath, kind, err)
|
||||
}
|
||||
|
||||
func entryFromInfo(logicalPath string, info fs.FileInfo) storage.Entry {
|
||||
entryType := storage.EntryTypeOther
|
||||
switch {
|
||||
case info.Mode()&os.ModeSymlink != 0:
|
||||
entryType = storage.EntryTypeSymlink
|
||||
case info.Mode().IsRegular():
|
||||
entryType = storage.EntryTypeFile
|
||||
case info.IsDir():
|
||||
entryType = storage.EntryTypeDirectory
|
||||
}
|
||||
return storage.Entry{
|
||||
Path: logicalPath,
|
||||
Type: entryType,
|
||||
Size: info.Size(),
|
||||
}
|
||||
}
|
||||
84
internal/adapters/ssh/hostkeys.go
Normal file
84
internal/adapters/ssh/hostkeys.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
func hostKeyCallback(options Options) (cryptossh.HostKeyCallback, error) {
|
||||
switch options.HostKeyPolicy {
|
||||
case HostKeyPolicyOff:
|
||||
return cryptossh.InsecureIgnoreHostKey(), nil
|
||||
case HostKeyPolicyStrict:
|
||||
if options.KnownHosts == "" {
|
||||
return nil, fmt.Errorf("known_hosts is required for strict host key checking")
|
||||
}
|
||||
callback, err := knownhosts.New(options.KnownHosts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load known_hosts %q: %w", options.KnownHosts, err)
|
||||
}
|
||||
return callback, nil
|
||||
case HostKeyPolicyAcceptNew:
|
||||
return acceptNewHostKeyCallback(options)
|
||||
default:
|
||||
return nil, fmt.Errorf("host_key_policy must be strict, accept-new, or off")
|
||||
}
|
||||
}
|
||||
|
||||
func acceptNewHostKeyCallback(options Options) (cryptossh.HostKeyCallback, error) {
|
||||
var checker cryptossh.HostKeyCallback
|
||||
if options.KnownHosts != "" {
|
||||
loaded, err := knownhosts.New(options.KnownHosts)
|
||||
if err == nil {
|
||||
checker = loaded
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("load known_hosts %q: %w", options.KnownHosts, err)
|
||||
}
|
||||
}
|
||||
return func(hostname string, remote net.Addr, key cryptossh.PublicKey) error {
|
||||
if checker != nil {
|
||||
err := checker(hostname, remote, key)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var keyErr *knownhosts.KeyError
|
||||
if !errors.As(err, &keyErr) {
|
||||
return err
|
||||
}
|
||||
if len(keyErr.Want) > 0 {
|
||||
return fmt.Errorf("host key for %s has changed: %w", hostname, err)
|
||||
}
|
||||
}
|
||||
if options.ReadOnlyKnownHosts {
|
||||
return nil
|
||||
}
|
||||
if options.KnownHosts == "" {
|
||||
return fmt.Errorf("host key for %s is unknown and no writable known_hosts path is available", hostname)
|
||||
}
|
||||
if err := appendKnownHost(options.KnownHosts, hostname, key); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded, err := knownhosts.New(options.KnownHosts)
|
||||
if err == nil {
|
||||
checker = loaded
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appendKnownHost(path, host string, key cryptossh.PublicKey) error {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist accepted host key to known_hosts %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := fmt.Fprintln(file, knownhosts.Line([]string{knownhosts.Normalize(host)}, key)); err != nil {
|
||||
return fmt.Errorf("persist accepted host key to known_hosts %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
138
internal/adapters/ssh/hostkeys_test.go
Normal file
138
internal/adapters/ssh/hostkeys_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
func TestAcceptNewHostKeyCallbackPersistsUnknownHost(t *testing.T) {
|
||||
key := testPublicKey(t)
|
||||
knownHosts := filepath.Join(t.TempDir(), "known_hosts")
|
||||
callback, err := acceptNewHostKeyCallback(Options{KnownHosts: knownHosts})
|
||||
if err != nil {
|
||||
t.Fatalf("acceptNewHostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
if err := callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, key); err != nil {
|
||||
t.Fatalf("callback() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(knownHosts)
|
||||
if err != nil {
|
||||
t.Fatalf("read known_hosts: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "example.com") {
|
||||
t.Fatalf("known_hosts = %q, want example.com entry", data)
|
||||
}
|
||||
if err := callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, key); err != nil {
|
||||
t.Fatalf("second callback() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptNewHostKeyCallbackReadOnlyDoesNotPersistUnknownHost(t *testing.T) {
|
||||
key := testPublicKey(t)
|
||||
knownHosts := filepath.Join(t.TempDir(), "known_hosts")
|
||||
callback, err := acceptNewHostKeyCallback(Options{
|
||||
KnownHosts: knownHosts,
|
||||
ReadOnlyKnownHosts: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("acceptNewHostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
if err := callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, key); err != nil {
|
||||
t.Fatalf("callback() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(knownHosts); !os.IsNotExist(err) {
|
||||
t.Fatalf("known_hosts stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptNewHostKeyCallbackRejectsChangedHostKey(t *testing.T) {
|
||||
first := testPublicKey(t)
|
||||
second := testPublicKey(t)
|
||||
knownHosts := filepath.Join(t.TempDir(), "known_hosts")
|
||||
if err := os.WriteFile(knownHosts, []byte(knownhosts.Line([]string{knownhosts.Normalize("example.com:22")}, first)+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
callback, err := acceptNewHostKeyCallback(Options{KnownHosts: knownHosts})
|
||||
if err != nil {
|
||||
t.Fatalf("acceptNewHostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
err = callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, second)
|
||||
if err == nil || !strings.Contains(err.Error(), "has changed") {
|
||||
t.Fatalf("callback() error = %v, want changed host key", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptNewHostKeyCallbackReadOnlyRejectsChangedHostKey(t *testing.T) {
|
||||
first := testPublicKey(t)
|
||||
second := testPublicKey(t)
|
||||
knownHosts := filepath.Join(t.TempDir(), "known_hosts")
|
||||
if err := os.WriteFile(knownHosts, []byte(knownhosts.Line([]string{knownhosts.Normalize("example.com:22")}, first)+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
callback, err := acceptNewHostKeyCallback(Options{
|
||||
KnownHosts: knownHosts,
|
||||
ReadOnlyKnownHosts: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("acceptNewHostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
err = callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, second)
|
||||
if err == nil || !strings.Contains(err.Error(), "has changed") {
|
||||
t.Fatalf("callback() error = %v, want changed host key", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptNewHostKeyCallbackRequiresWritableKnownHostsForUnknownHost(t *testing.T) {
|
||||
callback, err := acceptNewHostKeyCallback(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("acceptNewHostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
err = callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, testPublicKey(t))
|
||||
if err == nil || !strings.Contains(err.Error(), "no writable known_hosts path") {
|
||||
t.Fatalf("callback() error = %v, want no writable known_hosts path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptNewHostKeyCallbackReadOnlyAllowsMissingKnownHosts(t *testing.T) {
|
||||
callback, err := acceptNewHostKeyCallback(Options{ReadOnlyKnownHosts: true})
|
||||
if err != nil {
|
||||
t.Fatalf("acceptNewHostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
if err := callback("example.com:22", &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 22}, testPublicKey(t)); err != nil {
|
||||
t.Fatalf("callback() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictHostKeyCallbackRequiresKnownHosts(t *testing.T) {
|
||||
_, err := hostKeyCallback(Options{HostKeyPolicy: HostKeyPolicyStrict})
|
||||
if err == nil || !strings.Contains(err.Error(), "known_hosts is required") {
|
||||
t.Fatalf("hostKeyCallback() error = %v, want known_hosts required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicKey(t *testing.T) cryptossh.PublicKey {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
publicKey, err := cryptossh.NewPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("new public key: %v", err)
|
||||
}
|
||||
return publicKey
|
||||
}
|
||||
39
internal/adapters/ssh/integration_test.go
Normal file
39
internal/adapters/ssh/integration_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntegrationSSHBackendStatRoot(t *testing.T) {
|
||||
host := os.Getenv("DISTRIBUTOR_TEST_SSH_HOST")
|
||||
if host == "" {
|
||||
t.Skip("DISTRIBUTOR_TEST_SSH_HOST is not set")
|
||||
}
|
||||
port := 22
|
||||
if raw := os.Getenv("DISTRIBUTOR_TEST_SSH_PORT"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DISTRIBUTOR_TEST_SSH_PORT: %v", err)
|
||||
}
|
||||
port = parsed
|
||||
}
|
||||
backend, err := New(context.Background(), Options{
|
||||
Host: host,
|
||||
User: os.Getenv("DISTRIBUTOR_TEST_SSH_USER"),
|
||||
Port: port,
|
||||
Root: os.Getenv("DISTRIBUTOR_TEST_SSH_PATH"),
|
||||
KeyFile: os.Getenv("DISTRIBUTOR_TEST_SSH_KEY_FILE"),
|
||||
KnownHosts: os.Getenv("DISTRIBUTOR_TEST_SSH_KNOWN_HOSTS"),
|
||||
HostKeyPolicy: HostKeyPolicyStrict,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
defer backend.Close()
|
||||
if _, err := backend.Stat(context.Background(), ""); err != nil {
|
||||
t.Fatalf("Stat(root) error = %v", err)
|
||||
}
|
||||
}
|
||||
78
internal/adapters/ssh/options.go
Normal file
78
internal/adapters/ssh/options.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
BackendName = "ssh"
|
||||
|
||||
HostKeyPolicyStrict HostKeyPolicy = "strict"
|
||||
HostKeyPolicyAcceptNew HostKeyPolicy = "accept-new"
|
||||
HostKeyPolicyOff HostKeyPolicy = "off"
|
||||
)
|
||||
|
||||
type HostKeyPolicy string
|
||||
|
||||
type Options struct {
|
||||
Host string
|
||||
User string
|
||||
Port int
|
||||
Root string
|
||||
KeyFile string
|
||||
KnownHosts string
|
||||
HostKeyPolicy HostKeyPolicy
|
||||
ReadOnlyKnownHosts bool
|
||||
}
|
||||
|
||||
func (o Options) normalized() (Options, error) {
|
||||
if o.Host == "" {
|
||||
return Options{}, fmt.Errorf("host is required")
|
||||
}
|
||||
if o.User == "" {
|
||||
current, err := user.Current()
|
||||
if err != nil || current.Username == "" {
|
||||
return Options{}, fmt.Errorf("user is required when current OS user cannot be determined")
|
||||
}
|
||||
o.User = current.Username
|
||||
}
|
||||
if o.Port == 0 {
|
||||
o.Port = 22
|
||||
}
|
||||
if o.Port < 1 || o.Port > 65535 {
|
||||
return Options{}, fmt.Errorf("port must be between 1 and 65535")
|
||||
}
|
||||
if o.Root == "" {
|
||||
return Options{}, fmt.Errorf("path is required")
|
||||
}
|
||||
o.Root = path.Clean(o.Root)
|
||||
if o.HostKeyPolicy == "" {
|
||||
o.HostKeyPolicy = HostKeyPolicyAcceptNew
|
||||
}
|
||||
switch o.HostKeyPolicy {
|
||||
case HostKeyPolicyStrict, HostKeyPolicyAcceptNew, HostKeyPolicyOff:
|
||||
default:
|
||||
return Options{}, fmt.Errorf("host_key_policy must be strict, accept-new, or off")
|
||||
}
|
||||
if o.KnownHosts == "" && o.HostKeyPolicy != HostKeyPolicyOff {
|
||||
o.KnownHosts = defaultKnownHostsPath()
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (o Options) address() string {
|
||||
return o.Host + ":" + strconv.Itoa(o.Port)
|
||||
}
|
||||
|
||||
func defaultKnownHostsPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".ssh", "known_hosts")
|
||||
}
|
||||
118
internal/adapters/ssh/options_test.go
Normal file
118
internal/adapters/ssh/options_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
func TestOptionsNormalizeDefaultsUserPortAndHostKeyPolicy(t *testing.T) {
|
||||
options, err := (Options{
|
||||
Host: "example.com",
|
||||
Root: "/reports",
|
||||
}).normalized()
|
||||
if err != nil {
|
||||
t.Fatalf("normalized() error = %v", err)
|
||||
}
|
||||
if options.User == "" {
|
||||
t.Fatal("normalized user is empty")
|
||||
}
|
||||
if options.Port != 22 {
|
||||
t.Fatalf("port = %d, want 22", options.Port)
|
||||
}
|
||||
if options.HostKeyPolicy != HostKeyPolicyAcceptNew {
|
||||
t.Fatalf("host key policy = %q, want accept-new", options.HostKeyPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsNormalizeRejectsInvalidFields(t *testing.T) {
|
||||
tests := map[string]Options{
|
||||
"host": {Root: "/reports"},
|
||||
"port": {
|
||||
Host: "example.com",
|
||||
Port: 70000,
|
||||
Root: "/reports",
|
||||
},
|
||||
"path": {
|
||||
Host: "example.com",
|
||||
},
|
||||
"host key policy": {
|
||||
Host: "example.com",
|
||||
Root: "/reports",
|
||||
HostKeyPolicy: "prompt",
|
||||
},
|
||||
}
|
||||
for name, options := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := options.normalized(); err == nil {
|
||||
t.Fatal("normalized() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePathEnforcesLogicalPathRules(t *testing.T) {
|
||||
backend := &Backend{root: "/srv/reports"}
|
||||
tests := map[string]string{
|
||||
"bundle/report.md": "/srv/reports/bundle/report.md",
|
||||
"": "/srv/reports",
|
||||
}
|
||||
for logicalPath, want := range tests {
|
||||
t.Run(logicalPath, func(t *testing.T) {
|
||||
got, err := backend.nativePath(logicalPath, true)
|
||||
if err != nil {
|
||||
t.Fatalf("nativePath() error = %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("nativePath() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, logicalPath := range []string{"/absolute", "../escape", "a/../b", `a\b`} {
|
||||
t.Run("reject "+logicalPath, func(t *testing.T) {
|
||||
_, err := backend.nativePath(logicalPath, true)
|
||||
if err == nil || !storage.IsInvalidPath(err) {
|
||||
t.Fatalf("nativePath() error = %v, want invalid path", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsMissingAuthBeforeDial(t *testing.T) {
|
||||
t.Setenv("SSH_AUTH_SOCK", "")
|
||||
_, err := New(context.Background(), Options{
|
||||
Host: "example.com",
|
||||
User: "reports",
|
||||
Root: "/reports",
|
||||
HostKeyPolicy: HostKeyPolicyOff,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "no SSH auth methods configured") {
|
||||
t.Fatalf("New() error = %v, want missing auth", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateErrorMapsSFTPStatusCodes(t *testing.T) {
|
||||
backend := &Backend{}
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want storage.ErrorKind
|
||||
}{
|
||||
{name: "not found", err: sftp.ErrSSHFxNoSuchFile, want: storage.ErrNotFound},
|
||||
{name: "permission", err: sftp.ErrSSHFxPermissionDenied, want: storage.ErrPermission},
|
||||
{name: "unsupported", err: sftp.ErrSSHFxOpUnsupported, want: storage.ErrUnsupported},
|
||||
{name: "temporary", err: sftp.ErrSSHFxConnectionLost, want: storage.ErrTemporary},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := backend.translateError(storage.OpStat, "report.md", tt.err)
|
||||
if !storage.IsKind(err, tt.want) {
|
||||
t.Fatalf("translateError() = %v, want kind %s", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
10
internal/app/app.go
Normal file
10
internal/app/app.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package app
|
||||
|
||||
const Name = "distributor"
|
||||
|
||||
// Version can be replaced at build time with -ldflags "-X .../internal/app.Version=<value>".
|
||||
var Version = "dev"
|
||||
|
||||
func VersionString() string {
|
||||
return Name + " " + Version
|
||||
}
|
||||
16
internal/app/app_test.go
Normal file
16
internal/app/app_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVersionString(t *testing.T) {
|
||||
oldVersion := Version
|
||||
t.Cleanup(func() {
|
||||
Version = oldVersion
|
||||
})
|
||||
|
||||
Version = "1.2.3"
|
||||
|
||||
if got, want := VersionString(), "distributor 1.2.3"; got != want {
|
||||
t.Fatalf("VersionString() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
192
internal/app/backends.go
Normal file
192
internal/app/backends.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/adapters/local"
|
||||
s3adapter "gitea.maximumdirect.net/eric/distributor/internal/adapters/s3"
|
||||
sshadapter "gitea.maximumdirect.net/eric/distributor/internal/adapters/ssh"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
const storagePathKey = "path"
|
||||
|
||||
const (
|
||||
sshHostKey = "host"
|
||||
sshUserKey = "user"
|
||||
sshPortKey = "port"
|
||||
sshKeyFileKey = "ssh_key_file"
|
||||
sshKnownHostsKey = "known_hosts"
|
||||
sshHostKeyPolicyKey = "host_key_policy"
|
||||
sshReadOnlyHostsKey = "read_only_known_hosts"
|
||||
s3EndpointKey = "endpoint"
|
||||
s3BucketKey = "bucket"
|
||||
s3PrefixKey = "prefix"
|
||||
s3RegionKey = "region"
|
||||
s3ForcePathStyleKey = "force_path_style"
|
||||
s3AccessKeyIDKey = "access_key_id"
|
||||
s3SecretAccessKey = "secret_access_key"
|
||||
)
|
||||
|
||||
type backendFactory struct {
|
||||
registry *storage.Registry
|
||||
environment config.Environment
|
||||
readOnlyKnownHosts bool
|
||||
}
|
||||
|
||||
func newBackendFactory() *backendFactory {
|
||||
return newBackendFactoryWithEnvironment(config.ProcessEnvironment())
|
||||
}
|
||||
|
||||
func newBackendFactoryWithEnvironment(environment config.Environment) *backendFactory {
|
||||
registry := storage.NewRegistry()
|
||||
_ = registry.Register(config.BackendLocal, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return local.New(cfg[storagePathKey])
|
||||
})
|
||||
_ = registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
port, err := strconv.Atoi(cfg[sshPortKey])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh port: %w", err)
|
||||
}
|
||||
readOnlyKnownHosts := false
|
||||
if raw := cfg[sshReadOnlyHostsKey]; raw != "" {
|
||||
readOnlyKnownHosts, err = strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh read_only_known_hosts: %w", err)
|
||||
}
|
||||
}
|
||||
return sshadapter.New(ctx, sshadapter.Options{
|
||||
Host: cfg[sshHostKey],
|
||||
User: cfg[sshUserKey],
|
||||
Port: port,
|
||||
Root: cfg[storagePathKey],
|
||||
KeyFile: cfg[sshKeyFileKey],
|
||||
KnownHosts: cfg[sshKnownHostsKey],
|
||||
HostKeyPolicy: sshadapter.HostKeyPolicy(cfg[sshHostKeyPolicyKey]),
|
||||
ReadOnlyKnownHosts: readOnlyKnownHosts,
|
||||
})
|
||||
})
|
||||
_ = registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
forcePathStyle, err := strconv.ParseBool(cfg[s3ForcePathStyleKey])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 force_path_style: %w", err)
|
||||
}
|
||||
return s3adapter.New(ctx, s3adapter.Options{
|
||||
Endpoint: cfg[s3EndpointKey],
|
||||
Bucket: cfg[s3BucketKey],
|
||||
Prefix: cfg[s3PrefixKey],
|
||||
Region: cfg[s3RegionKey],
|
||||
ForcePathStyle: forcePathStyle,
|
||||
AccessKeyID: cfg[s3AccessKeyIDKey],
|
||||
SecretAccessKey: cfg[s3SecretAccessKey],
|
||||
})
|
||||
})
|
||||
return &backendFactory{registry: registry, environment: environment}
|
||||
}
|
||||
|
||||
func (f *backendFactory) openSource(ctx context.Context, source config.Backend) (storage.Backend, error) {
|
||||
if source.Backend != config.BackendLocal && source.Backend != config.BackendSSH && source.Backend != config.BackendS3 {
|
||||
return nil, fmt.Errorf("source backend %s is not implemented for execution", source.Backend)
|
||||
}
|
||||
openConfig, err := f.sourceOpenConfig(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.registry.Open(ctx, source.Backend, openConfig)
|
||||
}
|
||||
|
||||
func (f *backendFactory) openDestination(ctx context.Context, destination config.Destination) (storage.Backend, error) {
|
||||
if destination.Backend != config.BackendLocal && destination.Backend != config.BackendSSH && destination.Backend != config.BackendS3 {
|
||||
return nil, fmt.Errorf("backend %s is not implemented for execution", destination.Backend)
|
||||
}
|
||||
openConfig, err := f.destinationOpenConfig(destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.registry.Open(ctx, destination.Backend, openConfig)
|
||||
}
|
||||
|
||||
func (f *backendFactory) openLocalPath(ctx context.Context, path string) (storage.Backend, error) {
|
||||
return f.registry.Open(ctx, config.BackendLocal, storage.OpenConfig{storagePathKey: path})
|
||||
}
|
||||
|
||||
func (f *backendFactory) resolveCredentials(creds config.Credentials) (config.ResolvedCredentials, error) {
|
||||
return f.environment.ResolveCredentials(creds)
|
||||
}
|
||||
|
||||
func (f *backendFactory) sourceOpenConfig(source config.Backend) (storage.OpenConfig, error) {
|
||||
cfg := sourceOpenConfig(source)
|
||||
if source.Backend == config.BackendS3 {
|
||||
if err := f.addS3Config(cfg, source.Endpoint, source.Bucket, source.Prefix, source.Region, source.ForcePath, source.Creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if source.Backend == config.BackendSSH {
|
||||
cfg[sshReadOnlyHostsKey] = strconv.FormatBool(f.readOnlyKnownHosts)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (f *backendFactory) destinationOpenConfig(destination config.Destination) (storage.OpenConfig, error) {
|
||||
cfg := destinationOpenConfig(destination)
|
||||
if destination.Backend == config.BackendS3 {
|
||||
if err := f.addS3Config(cfg, destination.Endpoint, destination.Bucket, destination.Prefix, destination.Region, destination.ForcePath, destination.Creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if destination.Backend == config.BackendSSH {
|
||||
cfg[sshReadOnlyHostsKey] = strconv.FormatBool(f.readOnlyKnownHosts)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (f *backendFactory) addS3Config(cfg storage.OpenConfig, endpoint, bucket, prefix, region string, forcePath *bool, creds config.Credentials) error {
|
||||
cfg[s3EndpointKey] = endpoint
|
||||
cfg[s3BucketKey] = bucket
|
||||
cfg[s3PrefixKey] = prefix
|
||||
cfg[s3RegionKey] = region
|
||||
cfg[s3ForcePathStyleKey] = strconv.FormatBool(config.ForcePathStyle(forcePath))
|
||||
if creds.AccessKeyIDEnv != "" || creds.SecretAccessKeyEnv != "" {
|
||||
resolved, err := f.resolveCredentials(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg[s3AccessKeyIDKey] = resolved.AccessKeyID
|
||||
cfg[s3SecretAccessKey] = resolved.SecretAccessKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceOpenConfig(source config.Backend) storage.OpenConfig {
|
||||
cfg := storage.OpenConfig{storagePathKey: source.Path}
|
||||
if source.Backend == config.BackendSSH {
|
||||
cfg[sshHostKey] = source.Host
|
||||
cfg[sshUserKey] = source.User
|
||||
cfg[sshPortKey] = strconv.Itoa(source.Port)
|
||||
cfg[sshKeyFileKey] = source.SSH.KeyFile
|
||||
cfg[sshKnownHostsKey] = source.SSH.KnownHosts
|
||||
cfg[sshHostKeyPolicyKey] = string(source.SSH.HostKeyPolicy)
|
||||
cfg[sshReadOnlyHostsKey] = "false"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func destinationOpenConfig(destination config.Destination) storage.OpenConfig {
|
||||
cfg := storage.OpenConfig{storagePathKey: destination.Path}
|
||||
if destination.Backend == config.BackendSSH {
|
||||
cfg[sshHostKey] = destination.Host
|
||||
cfg[sshUserKey] = destination.User
|
||||
cfg[sshPortKey] = strconv.Itoa(destination.Port)
|
||||
cfg[sshKeyFileKey] = destination.SSH.KeyFile
|
||||
cfg[sshKnownHostsKey] = destination.SSH.KnownHosts
|
||||
cfg[sshHostKeyPolicyKey] = string(destination.SSH.HostKeyPolicy)
|
||||
cfg[sshReadOnlyHostsKey] = "false"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
330
internal/app/backends_test.go
Normal file
330
internal/app/backends_test.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
)
|
||||
|
||||
func TestBackendFactoryOpensLocalSource(t *testing.T) {
|
||||
factory := newBackendFactory()
|
||||
backend, err := factory.openSource(context.Background(), config.Backend{
|
||||
Backend: config.BackendLocal,
|
||||
Path: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openSource() error = %v", err)
|
||||
}
|
||||
if backend == nil {
|
||||
t.Fatal("openSource() backend = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryOpensLocalDestination(t *testing.T) {
|
||||
factory := newBackendFactory()
|
||||
backend, err := factory.openDestination(context.Background(), config.Destination{
|
||||
Backend: config.BackendLocal,
|
||||
Path: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openDestination() error = %v", err)
|
||||
}
|
||||
if backend == nil {
|
||||
t.Fatal("openDestination() backend = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryOpensDirectLocalPath(t *testing.T) {
|
||||
factory := newBackendFactory()
|
||||
backend, err := factory.openLocalPath(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("openLocalPath() error = %v", err)
|
||||
}
|
||||
if backend == nil {
|
||||
t.Fatal("openLocalPath() backend = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryOpensSSHSourceWithRegisteredOpener(t *testing.T) {
|
||||
factory := &backendFactory{registry: storage.NewRegistry()}
|
||||
var got storage.OpenConfig
|
||||
if err := factory.registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
backend, err := factory.openSource(context.Background(), config.Backend{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "source.example.com",
|
||||
User: "reports",
|
||||
Port: 22,
|
||||
Path: "/reports",
|
||||
SSH: config.SSH{HostKeyPolicy: config.HostKeyPolicyAcceptNew},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openSource() error = %v", err)
|
||||
}
|
||||
if backend == nil {
|
||||
t.Fatal("openSource() backend = nil")
|
||||
}
|
||||
if got[sshHostKey] != "source.example.com" || got[storagePathKey] != "/reports" {
|
||||
t.Fatalf("open config = %#v, want SSH source fields", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryOpensSSHDestinationWithRegisteredOpener(t *testing.T) {
|
||||
factory := &backendFactory{registry: storage.NewRegistry()}
|
||||
var got storage.OpenConfig
|
||||
if err := factory.registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
backend, err := factory.openDestination(context.Background(), config.Destination{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "destination.example.com",
|
||||
User: "deploy",
|
||||
Port: 2222,
|
||||
Path: "/archive",
|
||||
SSH: config.SSH{HostKeyPolicy: config.HostKeyPolicyStrict},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openDestination() error = %v", err)
|
||||
}
|
||||
if backend == nil {
|
||||
t.Fatal("openDestination() backend = nil")
|
||||
}
|
||||
if got[sshHostKey] != "destination.example.com" || got[sshPortKey] != "2222" || got[sshHostKeyPolicyKey] != "strict" {
|
||||
t.Fatalf("open config = %#v, want SSH destination fields", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactorySetsReadOnlyKnownHostsForDryRunSSH(t *testing.T) {
|
||||
factory := &backendFactory{
|
||||
registry: storage.NewRegistry(),
|
||||
readOnlyKnownHosts: true,
|
||||
}
|
||||
var got storage.OpenConfig
|
||||
if err := factory.registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := factory.openDestination(context.Background(), config.Destination{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "destination.example.com",
|
||||
User: "deploy",
|
||||
Port: 22,
|
||||
Path: "/archive",
|
||||
SSH: config.SSH{HostKeyPolicy: config.HostKeyPolicyAcceptNew},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openDestination() error = %v", err)
|
||||
}
|
||||
if got[sshReadOnlyHostsKey] != "true" {
|
||||
t.Fatalf("open config %s = %q, want true", sshReadOnlyHostsKey, got[sshReadOnlyHostsKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryUsesPersistentKnownHostsByDefault(t *testing.T) {
|
||||
factory := &backendFactory{registry: storage.NewRegistry()}
|
||||
var got storage.OpenConfig
|
||||
if err := factory.registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := factory.openDestination(context.Background(), config.Destination{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "destination.example.com",
|
||||
User: "deploy",
|
||||
Port: 22,
|
||||
Path: "/archive",
|
||||
SSH: config.SSH{HostKeyPolicy: config.HostKeyPolicyAcceptNew},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openDestination() error = %v", err)
|
||||
}
|
||||
if got[sshReadOnlyHostsKey] != "false" {
|
||||
t.Fatalf("open config %s = %q, want false", sshReadOnlyHostsKey, got[sshReadOnlyHostsKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryRejectsUnsupportedSource(t *testing.T) {
|
||||
factory := newBackendFactory()
|
||||
_, err := factory.openSource(context.Background(), config.Backend{
|
||||
Backend: "ftp",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "source backend ftp is not implemented for execution") {
|
||||
t.Fatalf("openSource() error = %v, want not implemented", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryRejectsUnsupportedDestination(t *testing.T) {
|
||||
factory := newBackendFactory()
|
||||
_, err := factory.openDestination(context.Background(), config.Destination{
|
||||
Backend: "ftp",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "backend ftp is not implemented for execution") {
|
||||
t.Fatalf("openDestination() error = %v, want not implemented", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryOpensS3DestinationWithRegisteredOpener(t *testing.T) {
|
||||
factory := &backendFactory{
|
||||
registry: storage.NewRegistry(),
|
||||
environment: config.NewEnvironment(nil, func(string) (string, bool) { return "", false }),
|
||||
}
|
||||
var got storage.OpenConfig
|
||||
if err := factory.registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
forcePathStyle := false
|
||||
backend, err := factory.openDestination(context.Background(), config.Destination{
|
||||
Backend: config.BackendS3,
|
||||
Endpoint: "https://s3.example.com",
|
||||
Bucket: "reports",
|
||||
Prefix: "archive",
|
||||
Region: config.DefaultS3Region,
|
||||
ForcePath: &forcePathStyle,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openDestination() error = %v", err)
|
||||
}
|
||||
if backend == nil {
|
||||
t.Fatal("openDestination() backend = nil")
|
||||
}
|
||||
assertOpenConfig(t, got, map[string]string{
|
||||
s3EndpointKey: "https://s3.example.com",
|
||||
s3BucketKey: "reports",
|
||||
s3PrefixKey: "archive",
|
||||
s3RegionKey: config.DefaultS3Region,
|
||||
s3ForcePathStyleKey: "false",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackendFactoryResolvesS3CredentialsThroughSecretsAwareEnvironment(t *testing.T) {
|
||||
factory := &backendFactory{
|
||||
registry: storage.NewRegistry(),
|
||||
environment: config.NewEnvironment(map[string]string{
|
||||
"ACCESS_KEY_ID": "secret-access",
|
||||
"SECRET_ACCESS_KEY": "secret-secret",
|
||||
}, func(string) (string, bool) { return "", false }),
|
||||
}
|
||||
var got storage.OpenConfig
|
||||
if err := factory.registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
forcePathStyle := true
|
||||
_, err := factory.openSource(context.Background(), config.Backend{
|
||||
Backend: config.BackendS3,
|
||||
Endpoint: "https://s3.example.com",
|
||||
Bucket: "reports",
|
||||
Region: config.DefaultS3Region,
|
||||
ForcePath: &forcePathStyle,
|
||||
Creds: config.Credentials{
|
||||
AccessKeyIDEnv: "ACCESS_KEY_ID",
|
||||
SecretAccessKeyEnv: "SECRET_ACCESS_KEY",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openSource() error = %v", err)
|
||||
}
|
||||
assertOpenConfig(t, got, map[string]string{
|
||||
s3AccessKeyIDKey: "secret-access",
|
||||
s3SecretAccessKey: "secret-secret",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackendFactoryResolvesCredentialsThroughEnvironment(t *testing.T) {
|
||||
factory := newBackendFactoryWithEnvironment(config.NewEnvironment(map[string]string{
|
||||
"ACCESS_KEY_ID": "secret-access",
|
||||
"SECRET_ACCESS_KEY": "secret-secret",
|
||||
}, func(string) (string, bool) {
|
||||
return "", false
|
||||
}))
|
||||
|
||||
creds, err := factory.resolveCredentials(config.Credentials{
|
||||
AccessKeyIDEnv: "ACCESS_KEY_ID",
|
||||
SecretAccessKeyEnv: "SECRET_ACCESS_KEY",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCredentials() error = %v", err)
|
||||
}
|
||||
if creds.AccessKeyID != "secret-access" || creds.SecretAccessKey != "secret-secret" {
|
||||
t.Fatalf("resolved credentials = %#v", creds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFactoryBuildsSSHSourceOpenConfig(t *testing.T) {
|
||||
cfg := sourceOpenConfig(config.Backend{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "source.example.com",
|
||||
User: "reports",
|
||||
Port: 2222,
|
||||
Path: "/reports",
|
||||
SSH: config.SSH{
|
||||
KeyFile: "/home/reports/.ssh/id_ed25519",
|
||||
KnownHosts: "/home/reports/.ssh/known_hosts",
|
||||
HostKeyPolicy: config.HostKeyPolicyStrict,
|
||||
},
|
||||
})
|
||||
|
||||
assertOpenConfig(t, cfg, map[string]string{
|
||||
storagePathKey: "/reports",
|
||||
sshHostKey: "source.example.com",
|
||||
sshUserKey: "reports",
|
||||
sshPortKey: "2222",
|
||||
sshKeyFileKey: "/home/reports/.ssh/id_ed25519",
|
||||
sshKnownHostsKey: "/home/reports/.ssh/known_hosts",
|
||||
sshHostKeyPolicyKey: "strict",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackendFactoryBuildsSSHDestinationOpenConfig(t *testing.T) {
|
||||
cfg := destinationOpenConfig(config.Destination{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "destination.example.com",
|
||||
User: "deploy",
|
||||
Port: 22,
|
||||
Path: "/srv/archive",
|
||||
SSH: config.SSH{
|
||||
HostKeyPolicy: config.HostKeyPolicyAcceptNew,
|
||||
},
|
||||
})
|
||||
|
||||
assertOpenConfig(t, cfg, map[string]string{
|
||||
storagePathKey: "/srv/archive",
|
||||
sshHostKey: "destination.example.com",
|
||||
sshUserKey: "deploy",
|
||||
sshPortKey: "22",
|
||||
sshHostKeyPolicyKey: "accept-new",
|
||||
})
|
||||
}
|
||||
|
||||
func assertOpenConfig(t *testing.T, got map[string]string, want map[string]string) {
|
||||
t.Helper()
|
||||
for key, wantValue := range want {
|
||||
if gotValue := got[key]; gotValue != wantValue {
|
||||
t.Fatalf("open config %s = %q, want %q", key, gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
internal/app/inspect.go
Normal file
58
internal/app/inspect.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type InspectOptions struct {
|
||||
Path string
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
func Inspect(ctx context.Context, options InspectOptions) error {
|
||||
if options.Path == "" {
|
||||
return fmt.Errorf("inspect command requires a path")
|
||||
}
|
||||
backend, err := newBackendFactory().openLocalPath(ctx, options.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bundles, err := bundle.Discover(ctx, backend, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeInspection(options.Stdout, bundles)
|
||||
}
|
||||
|
||||
func writeInspection(w io.Writer, bundles []bundle.Bundle) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "Bundles: %d\n", len(bundles)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sourceBundle := range bundles {
|
||||
if _, err := fmt.Fprintf(
|
||||
w,
|
||||
"- path=%s id=%s created=%s digest=%s files=%d\n",
|
||||
storage.DisplayPath(sourceBundle.RootRelativePath),
|
||||
sourceBundle.Manifest.ID,
|
||||
sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"),
|
||||
sourceBundle.Manifest.Digest,
|
||||
len(sourceBundle.Manifest.Files),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range sourceBundle.Manifest.Files {
|
||||
if _, err := fmt.Fprintf(w, " - %s size=%d sha256=%s\n", file.Path, file.Size, file.SHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
40
internal/app/inspect_test.go
Normal file
40
internal/app/inspect_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInspectPrintsBundleSummary(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
err := Inspect(context.Background(), InspectOptions{
|
||||
Path: filepath.Join("..", "bundle", "testdata", "valid_bundle"),
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() error = %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Bundles: 1",
|
||||
"path=.",
|
||||
"id=weather.daily.brentwood.2026-05-30",
|
||||
"created=2026-05-30T11:10:00Z",
|
||||
"report.md size=16",
|
||||
"summary.txt size=8",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("Inspect() output = %q, want substring %q", output, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRequiresPath(t *testing.T) {
|
||||
err := Inspect(context.Background(), InspectOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a path") {
|
||||
t.Fatalf("Inspect() error = %v, want required path", err)
|
||||
}
|
||||
}
|
||||
352
internal/app/run.go
Normal file
352
internal/app/run.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/notify"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/publish"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type RunOptions struct {
|
||||
ConfigPath string
|
||||
DryRun bool
|
||||
Force bool
|
||||
Stdout io.Writer
|
||||
Notifier notify.Notifier
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, options RunOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := options.ConfigPath
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath
|
||||
}
|
||||
cfg, err := config.LoadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runConfig(ctx, cfg, options)
|
||||
}
|
||||
|
||||
func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error {
|
||||
return runConfigWithBackendFactory(ctx, cfg, options, newBackendFactoryWithEnvironment)
|
||||
}
|
||||
|
||||
type backendFactoryProvider func(config.Environment) *backendFactory
|
||||
|
||||
func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error {
|
||||
notifier := options.Notifier
|
||||
if notifier == nil {
|
||||
notifier = notify.Noop{}
|
||||
}
|
||||
summary := runSummary{dryRun: options.DryRun}
|
||||
var failures runFailures
|
||||
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if options.Stdout != nil {
|
||||
if err := writeSecretConflictWarnings(options.Stdout, secretLoad.Conflicts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
backends := provider(secretLoad.Environment)
|
||||
backends.readOnlyKnownHosts = options.DryRun
|
||||
transforms := newTransformRegistry()
|
||||
if options.Stdout != nil {
|
||||
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, pipeline := range cfg.Pipelines {
|
||||
if options.Stdout != nil {
|
||||
if err := writeSSHWarnings(options.Stdout, pipeline); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
sourceBackend, err := backends.openSource(ctx, pipeline.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err)
|
||||
}
|
||||
bundles, err := bundle.Discover(ctx, sourceBackend, "")
|
||||
if err != nil {
|
||||
closeBackend(sourceBackend)
|
||||
return fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
|
||||
}
|
||||
if options.Stdout != nil {
|
||||
if _, err := fmt.Fprintf(options.Stdout, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.Source.Backend, len(bundles), destinationSummary(pipeline.Destinations)); err != nil {
|
||||
closeBackend(sourceBackend)
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sourceBundle := range bundles {
|
||||
for _, destination := range pipeline.Destinations {
|
||||
destinationBackend, err := backends.openDestination(ctx, destination)
|
||||
if err != nil {
|
||||
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
|
||||
summary.recordFailure()
|
||||
if options.Stdout != nil {
|
||||
writeErrorLine(options.Stdout, sourceBundle.RootRelativePath, destination.ID, destination.Backend, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
closeDestination := true
|
||||
deferCloseDestination := func() {
|
||||
if closeDestination {
|
||||
closeBackend(destinationBackend)
|
||||
closeDestination = false
|
||||
}
|
||||
}
|
||||
req := publish.Request{
|
||||
PipelineID: pipeline.ID,
|
||||
DestinationID: destination.ID,
|
||||
SourceBundle: sourceBundle,
|
||||
SourceBackend: sourceBackend,
|
||||
DestinationBackend: destinationBackend,
|
||||
DestinationBundlePath: sourceBundle.RootRelativePath,
|
||||
Publish: *destination.Publish,
|
||||
Transform: destination.Transform,
|
||||
Transformers: transforms,
|
||||
Transfer: destination.Transfer,
|
||||
DistributorVersion: Version,
|
||||
Force: options.Force,
|
||||
}
|
||||
plan, err := publish.Build(ctx, req)
|
||||
if err != nil && plan.DestinationID == "" {
|
||||
plan = publish.Plan{DestinationID: destination.ID, BundlePath: sourceBundle.RootRelativePath}
|
||||
}
|
||||
if options.Stdout != nil {
|
||||
writePlanLine(options.Stdout, destination.Backend, plan, err)
|
||||
}
|
||||
if err != nil {
|
||||
deferCloseDestination()
|
||||
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
|
||||
summary.recordFailure()
|
||||
continue
|
||||
}
|
||||
summary.recordPlan(plan.Action)
|
||||
if !options.DryRun {
|
||||
if err := publish.Execute(ctx, req, plan); err != nil {
|
||||
deferCloseDestination()
|
||||
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
|
||||
summary.recordFailure()
|
||||
continue
|
||||
}
|
||||
if shouldNotify(plan.Action) {
|
||||
if err := notifier.Notify(ctx, notifyEvent(plan)); err != nil {
|
||||
deferCloseDestination()
|
||||
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
|
||||
summary.recordFailure()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
deferCloseDestination()
|
||||
}
|
||||
}
|
||||
closeBackend(sourceBackend)
|
||||
}
|
||||
if options.Stdout != nil {
|
||||
if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(failures.items) > 0 {
|
||||
return failures
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type closeableBackend interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func closeBackend(backend storage.Backend) {
|
||||
closeable, ok := backend.(closeableBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = closeable.Close()
|
||||
}
|
||||
|
||||
func writePlanLine(w io.Writer, backend string, plan publish.Plan, planErr error) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if planErr != nil {
|
||||
destinationID := plan.DestinationID
|
||||
if destinationID == "" {
|
||||
destinationID = "unknown"
|
||||
}
|
||||
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s action=error reason=%q\n", storage.DisplayPath(plan.BundlePath), destinationID, backend, planErr.Error())
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s action=%s outputs=%s reason=%q\n", storage.DisplayPath(plan.BundlePath), plan.DestinationID, backend, plan.Action, outputSummary(plan.Outputs), plan.Reason)
|
||||
}
|
||||
|
||||
func writeErrorLine(w io.Writer, bundlePath, destinationID, backend string, err error) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s action=error reason=%q\n", storage.DisplayPath(bundlePath), destinationID, backend, err.Error())
|
||||
}
|
||||
|
||||
func outputSummary(outputs []publish.Output) string {
|
||||
if len(outputs) == 0 {
|
||||
return "none"
|
||||
}
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.DestinationPath)
|
||||
}
|
||||
return strings.Join(paths, ",")
|
||||
}
|
||||
|
||||
func destinationSummary(destinations []config.Destination) string {
|
||||
if len(destinations) == 0 {
|
||||
return "none"
|
||||
}
|
||||
ids := make([]string, 0, len(destinations))
|
||||
for _, destination := range destinations {
|
||||
ids = append(ids, destination.ID)
|
||||
}
|
||||
return strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
func writeSecretConflictWarnings(w io.Writer, conflicts []config.SecretConflict) error {
|
||||
for _, conflict := range conflicts {
|
||||
if _, err := fmt.Fprintf(w, "Warning: secret %s ignored because the real environment already has that variable\n", conflict.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSSHWarnings(w io.Writer, pipeline config.Pipeline) error {
|
||||
if pipeline.Source.Backend == config.BackendSSH && pipeline.Source.SSH.HostKeyPolicy == config.HostKeyPolicyOff {
|
||||
if _, err := fmt.Fprintf(w, "Warning: pipeline=%s source host_key_policy=off disables SSH host key checking\n", pipeline.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, destination := range pipeline.Destinations {
|
||||
if destination.Backend == config.BackendSSH && destination.SSH.HostKeyPolicy == config.HostKeyPolicyOff {
|
||||
if _, err := fmt.Fprintf(w, "Warning: pipeline=%s destination=%s host_key_policy=off disables SSH host key checking\n", pipeline.ID, destination.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldNotify(action publish.Action) bool {
|
||||
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionForceReplace
|
||||
}
|
||||
|
||||
func notifyEvent(plan publish.Plan) notify.Event {
|
||||
outputs := make([]notify.Output, 0, len(plan.Outputs))
|
||||
for _, output := range plan.Outputs {
|
||||
outputs = append(outputs, notify.Output{
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
})
|
||||
}
|
||||
return notify.Event{
|
||||
PipelineID: plan.PipelineID,
|
||||
DestinationID: plan.DestinationID,
|
||||
BundleID: plan.BundleID,
|
||||
BundlePath: plan.BundlePath,
|
||||
Action: string(plan.Action),
|
||||
Outputs: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
type runSummary struct {
|
||||
dryRun bool
|
||||
planned int
|
||||
publishNew int
|
||||
replaceOlder int
|
||||
forceReplace int
|
||||
skipped int
|
||||
failures int
|
||||
}
|
||||
|
||||
func (s *runSummary) recordPlan(action publish.Action) {
|
||||
s.planned++
|
||||
switch action {
|
||||
case publish.ActionPublishNew:
|
||||
s.publishNew++
|
||||
case publish.ActionReplaceOlder:
|
||||
s.replaceOlder++
|
||||
case publish.ActionForceReplace:
|
||||
s.forceReplace++
|
||||
case publish.ActionSkipSame, publish.ActionSkipDestinationNewer:
|
||||
s.skipped++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *runSummary) recordFailure() {
|
||||
s.failures++
|
||||
}
|
||||
|
||||
func (s runSummary) Line() string {
|
||||
status := "ok"
|
||||
if s.failures > 0 {
|
||||
status = "failed"
|
||||
}
|
||||
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun)
|
||||
}
|
||||
|
||||
type runFailure struct {
|
||||
pipelineID string
|
||||
destinationID string
|
||||
backend string
|
||||
bundlePath string
|
||||
err error
|
||||
}
|
||||
|
||||
type runFailures struct {
|
||||
items []runFailure
|
||||
}
|
||||
|
||||
func (f *runFailures) add(pipelineID, destinationID, backend, bundlePath string, err error) {
|
||||
f.items = append(f.items, runFailure{
|
||||
pipelineID: pipelineID,
|
||||
destinationID: destinationID,
|
||||
backend: backend,
|
||||
bundlePath: bundlePath,
|
||||
err: err,
|
||||
})
|
||||
}
|
||||
|
||||
func (f runFailures) Error() string {
|
||||
if len(f.items) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(f.items))
|
||||
for _, item := range f.items {
|
||||
parts = append(parts, fmt.Sprintf("pipeline %s destination %s backend %s bundle %s: %v", item.pipelineID, item.destinationID, item.backend, item.bundlePath, item.err))
|
||||
}
|
||||
return "run failed: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func (f runFailures) Unwrap() error {
|
||||
errs := make([]error, 0, len(f.items))
|
||||
for _, item := range f.items {
|
||||
errs = append(errs, item.err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
990
internal/app/run_test.go
Normal file
990
internal/app/run_test.go
Normal file
@@ -0,0 +1,990 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/adapters/local"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/notify"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestRunDryRunPrintsConfigSummary(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
configPath := writeLocalConfig(t, sourceRoot, destinationRoot)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: configPath,
|
||||
DryRun: true,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Configured pipelines: 1",
|
||||
"- pipeline=reports source=local bundles=1 destinations=archive",
|
||||
"bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt",
|
||||
"Final status: ok planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("Run() output = %q, want substring %q", output, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunUsesReadOnlySSHKnownHosts(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
cfg := config.Config{Pipelines: []config.Pipeline{{
|
||||
ID: "reports",
|
||||
Source: config.Backend{Backend: config.BackendLocal, Path: sourceRoot},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
Backend: config.BackendSSH,
|
||||
Host: "destination.example.com",
|
||||
Path: "/archive",
|
||||
}},
|
||||
}}}
|
||||
config.ApplyDefaults(&cfg)
|
||||
var got storage.OpenConfig
|
||||
provider := func(environment config.Environment) *backendFactory {
|
||||
registry := storage.NewRegistry()
|
||||
if err := registry.Register(config.BackendLocal, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
return local.New(cfg[storagePathKey])
|
||||
}); err != nil {
|
||||
t.Fatalf("register local backend: %v", err)
|
||||
}
|
||||
if err := registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
got = cfg
|
||||
return fake.New(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("register ssh backend: %v", err)
|
||||
}
|
||||
return &backendFactory{registry: registry, environment: environment}
|
||||
}
|
||||
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{DryRun: true}, provider); err != nil {
|
||||
t.Fatalf("runConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if got[sshReadOnlyHostsKey] != "true" {
|
||||
t.Fatalf("open config %s = %q, want true", sshReadOnlyHostsKey, got[sshReadOnlyHostsKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoadsSecretsBeforeOpeningBackends(t *testing.T) {
|
||||
sourceRoot := filepath.Join(t.TempDir(), "missing-source")
|
||||
destinationRoot := t.TempDir()
|
||||
configPath := writeConfigFile(t, `
|
||||
secrets:
|
||||
directory: `+filepath.Join(t.TempDir(), "missing-secrets")+`
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: `+sourceRoot+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: `+destinationRoot+`
|
||||
`)
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: configPath})
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want secrets directory error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "load secrets directory") {
|
||||
t.Fatalf("Run() error = %v, want secrets directory error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "missing-source") {
|
||||
t.Fatalf("Run() error = %v, opened source before loading secrets", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPrintsSecretConflictWarningWithoutValues(t *testing.T) {
|
||||
name := "DISTRIBUTOR_TEST_RUN_SECRET"
|
||||
t.Setenv(name, "process-value")
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
secretsRoot := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(secretsRoot, name), []byte("secret-value\n"), 0o600); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
configPath := writeConfigFile(t, `
|
||||
secrets:
|
||||
directory: `+secretsRoot+`
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: `+sourceRoot+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: `+destinationRoot+`
|
||||
`)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: configPath,
|
||||
DryRun: true,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "secret "+name+" ignored because the real environment already has that variable") {
|
||||
t.Fatalf("stdout = %q, want secret conflict warning", output)
|
||||
}
|
||||
if strings.Contains(output, "process-value") || strings.Contains(output, "secret-value") {
|
||||
t.Fatalf("stdout exposed secret values: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSSHWarningsReportsInsecureHostKeyPolicy(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
err := writeSSHWarnings(&stdout, config.Pipeline{
|
||||
ID: "reports",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendSSH,
|
||||
SSH: config.SSH{HostKeyPolicy: config.HostKeyPolicyOff},
|
||||
},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
Backend: config.BackendSSH,
|
||||
SSH: config.SSH{HostKeyPolicy: config.HostKeyPolicyOff},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeSSHWarnings() error = %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"pipeline=reports source host_key_policy=off disables SSH host key checking",
|
||||
"pipeline=reports destination=archive host_key_policy=off disables SSH host key checking",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output = %q, want substring %q", output, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPublishesNewLocalBundle(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
assertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "manifest.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("destination manifest stat error = %v, want not exist", err)
|
||||
}
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if destinationState.PipelineID != "reports" || destinationState.DestinationID != "archive" {
|
||||
t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID)
|
||||
}
|
||||
if destinationState.Source.Manifest.ID != manifest.ID {
|
||||
t.Fatalf("state source id = %q, want %q", destinationState.Source.Manifest.ID, manifest.ID)
|
||||
}
|
||||
if got, want := len(destinationState.Outputs), 2; got != want {
|
||||
t.Fatalf("state output count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNotifiesAfterPublication(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
notifier := &recordingNotifier{
|
||||
check: func() {
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil {
|
||||
t.Fatalf("state stat during notify: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got, want := len(notifier.events), 1; got != want {
|
||||
t.Fatalf("notification count = %d, want %d", got, want)
|
||||
}
|
||||
event := notifier.events[0]
|
||||
if event.PipelineID != "reports" || event.DestinationID != "archive" || event.BundleID == "" || event.Action != "publish_new" {
|
||||
t.Fatalf("notification event = %#v", event)
|
||||
}
|
||||
if got, want := len(event.Outputs), 2; got != want {
|
||||
t.Fatalf("notification output count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNotifiesAfterReplacement(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
older := manifest
|
||||
older.Created = older.Created.Add(-time.Hour)
|
||||
writeDestinationState(t, destinationRoot, "", older)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
|
||||
t.Fatalf("write old output: %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got, want := len(notifier.events), 1; got != want {
|
||||
t.Fatalf("notification count = %d, want %d", got, want)
|
||||
}
|
||||
if notifier.events[0].Action != "replace_older" {
|
||||
t.Fatalf("notification action = %q, want replace_older", notifier.events[0].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
configPath := writeLocalConfig(t, sourceRoot, destinationRoot)
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Notifier: notifier})
|
||||
if err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
if len(notifier.events) != 0 {
|
||||
t.Fatalf("notifications = %#v, want none", notifier.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotNotifyDuringDryRun(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
DryRun: true,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(notifier.events) != 0 {
|
||||
t.Fatalf("notifications = %#v, want none", notifier.events)
|
||||
}
|
||||
if entries, err := os.ReadDir(destinationRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("destination entries = %v err=%v, want empty", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunContinuesAfterDestinationFailure(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
firstDestination := t.TempDir()
|
||||
secondDestination := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged file: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination),
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "fail_unmanaged") {
|
||||
t.Fatalf("Run() error = %v, want unmanaged failure", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pipeline reports destination archive-one backend local bundle .") {
|
||||
t.Fatalf("Run() error = %v, want backend context", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"destination=archive-one backend=local action=error",
|
||||
"destination=archive-two backend=local action=publish_new",
|
||||
"Final status: failed planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=1 dry_run=false",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stdout = %q, want substring %q", output, want)
|
||||
}
|
||||
}
|
||||
assertFile(t, filepath.Join(secondDestination, "report.md"), "# Report\nSunny.\n")
|
||||
}
|
||||
|
||||
func TestRunPublishesHTMLOnly(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, false, true)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertFileContains(t, filepath.Join(destinationRoot, "report.html"), "<h1>Report</h1>")
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "report.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("report.md stat error = %v, want not exist", err)
|
||||
}
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if got, want := len(destinationState.Outputs), 1; got != want {
|
||||
t.Fatalf("state output count = %d, want %d", got, want)
|
||||
}
|
||||
output := destinationState.Outputs[0]
|
||||
if output.Kind != state.OutputKindGenerated || output.Transform != "markdown_to_html" || output.Path != "report.html" || output.SourcePath != "report.md" {
|
||||
t.Fatalf("generated output metadata = %#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPublishesSourceAndHTML(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, true, true)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
assertFileContains(t, filepath.Join(destinationRoot, "report.html"), "<p>Sunny.</p>")
|
||||
assertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if got, want := len(destinationState.Outputs), 3; got != want {
|
||||
t.Fatalf("state output count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotMutateSourceBundle(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
sourcePath := filepath.Join(sourceRoot, "report.md")
|
||||
before, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read source before: %v", err)
|
||||
}
|
||||
|
||||
err = Run(context.Background(), RunOptions{ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, true, true)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
after, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read source after: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("source changed from %q to %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailsOnOutputPathCollision(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{ExtraFiles: []testFile{{Path: "report.html", Data: "<p>source html</p>\n"}}})
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, true, true)})
|
||||
if err == nil || !strings.Contains(err.Error(), "destination output path collision") {
|
||||
t.Fatalf("Run() error = %v, want collision", err)
|
||||
}
|
||||
if entries, err := os.ReadDir(destinationRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("destination entries = %v err=%v, want empty", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunReportsGeneratedOutputs(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfigWithPolicy(t, sourceRoot, destinationRoot, false, true),
|
||||
DryRun: true,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "outputs=report.html") {
|
||||
t.Fatalf("stdout = %q, want generated output path", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
configPath := writeLocalConfig(t, sourceRoot, destinationRoot)
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=skip_same") {
|
||||
t.Fatalf("stdout = %q, want skip_same", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReplacesOlderDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
older := manifest
|
||||
older.Created = older.Created.Add(-time.Hour)
|
||||
writeDestinationState(t, destinationRoot, "", older)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
|
||||
t.Fatalf("write old output: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=replace_older") {
|
||||
t.Fatalf("stdout = %q, want replace_older", stdout.String())
|
||||
}
|
||||
assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
}
|
||||
|
||||
func TestRunSkipsNewerDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
newer := manifest
|
||||
newer.Created = newer.Created.Add(time.Hour)
|
||||
writeDestinationState(t, destinationRoot, "", newer)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("newer\n"), 0o600); err != nil {
|
||||
t.Fatalf("write newer output: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=skip_destination_newer") {
|
||||
t.Fatalf("stdout = %q, want skip_destination_newer", stdout.String())
|
||||
}
|
||||
assertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n")
|
||||
}
|
||||
|
||||
func TestRunFailsOnConflict(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
manifest.ID = "other.source"
|
||||
writeDestinationState(t, destinationRoot, "", manifest)
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)})
|
||||
if err == nil || !strings.Contains(err.Error(), "fail_conflict") {
|
||||
t.Fatalf("Run() error = %v, want fail_conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailsOnUnmanagedDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged file: %v", err)
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)})
|
||||
if err == nil || !strings.Contains(err.Error(), "fail_unmanaged") {
|
||||
t.Fatalf("Run() error = %v, want fail_unmanaged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged file: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
Force: true,
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=force_replace") {
|
||||
t.Fatalf("stdout = %q, want force_replace", stdout.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged file stat error = %v, want not exist", err)
|
||||
}
|
||||
assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
}
|
||||
|
||||
func TestRunFansOutToLocalDestinations(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
firstDestination := t.TempDir()
|
||||
secondDestination := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "daily", testBundleOptions{})
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertFile(t, filepath.Join(firstDestination, "daily", "report.md"), "# Report\nSunny.\n")
|
||||
assertFile(t, filepath.Join(secondDestination, "daily", "summary.txt"), "Summary\n")
|
||||
}
|
||||
|
||||
func TestRunFansOutWithDifferentPublishPolicies(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
archiveDestination := t.TempDir()
|
||||
htmlDestination := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeMixedPolicyFanoutConfig(t, sourceRoot, archiveDestination, htmlDestination)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertFile(t, filepath.Join(archiveDestination, "report.md"), "# Report\nSunny.\n")
|
||||
assertFile(t, filepath.Join(archiveDestination, "summary.txt"), "Summary\n")
|
||||
if _, err := os.Stat(filepath.Join(archiveDestination, "report.html")); !os.IsNotExist(err) {
|
||||
t.Fatalf("archive report.html stat error = %v, want not exist", err)
|
||||
}
|
||||
assertFileContains(t, filepath.Join(htmlDestination, "report.html"), "<h1>Report</h1>")
|
||||
if _, err := os.Stat(filepath.Join(htmlDestination, "report.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("html report.md stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
|
||||
localSourceRoot := t.TempDir()
|
||||
writeSourceBundle(t, localSourceRoot, "", testBundleOptions{})
|
||||
s3Source := fake.New()
|
||||
testutil.WriteFakeSourceBundle(t, s3Source, "", testutil.BundleOptions{})
|
||||
sshSource := fake.New()
|
||||
testutil.WriteFakeSourceBundle(t, sshSource, "", testutil.BundleOptions{})
|
||||
s3Destination := fake.New()
|
||||
sshDestination := fake.New()
|
||||
s3ToLocalDestination := t.TempDir()
|
||||
sshToLocalDestination := t.TempDir()
|
||||
|
||||
cfg := crossBackendConfig(localSourceRoot, s3ToLocalDestination, sshToLocalDestination)
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
t.Fatalf("cross-backend config validation error = %v", err)
|
||||
}
|
||||
provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{
|
||||
"s3:source-bucket": s3Source,
|
||||
"s3:destination-bucket": s3Destination,
|
||||
"ssh:/source": sshSource,
|
||||
"ssh:/destination": sshDestination,
|
||||
})
|
||||
|
||||
var dryRunOutput bytes.Buffer
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{DryRun: true, Stdout: &dryRunOutput}, provider); err != nil {
|
||||
t.Fatalf("dry-run error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"pipeline=local-to-s3 source=local",
|
||||
"destination=object-archive backend=s3 action=publish_new",
|
||||
"pipeline=s3-to-local source=s3",
|
||||
"destination=local-archive backend=local action=publish_new",
|
||||
"pipeline=local-to-ssh source=local",
|
||||
"destination=ssh-archive backend=ssh action=publish_new",
|
||||
"pipeline=ssh-to-local source=ssh",
|
||||
"Final status: ok planned=4 publish_new=4 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true",
|
||||
} {
|
||||
if !strings.Contains(dryRunOutput.String(), want) {
|
||||
t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want)
|
||||
}
|
||||
}
|
||||
if hasAny, err := s3Destination.HasAny(context.Background(), ""); err != nil || hasAny {
|
||||
t.Fatalf("s3 destination after dry-run hasAny=%t err=%v, want empty", hasAny, err)
|
||||
}
|
||||
if entries, err := os.ReadDir(s3ToLocalDestination); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("s3-to-local destination entries = %v err=%v, want empty", entries, err)
|
||||
}
|
||||
|
||||
var publishOutput bytes.Buffer
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{Stdout: &publishOutput}, provider); err != nil {
|
||||
t.Fatalf("publish error = %v", err)
|
||||
}
|
||||
assertFakeFile(t, s3Destination, "report.md", "# Report\nSunny.\n")
|
||||
assertFakeFile(t, sshDestination, "summary.txt", "Summary\n")
|
||||
assertFile(t, filepath.Join(s3ToLocalDestination, "report.md"), "# Report\nSunny.\n")
|
||||
assertFile(t, filepath.Join(sshToLocalDestination, "summary.txt"), "Summary\n")
|
||||
|
||||
var repeatOutput bytes.Buffer
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{Stdout: &repeatOutput}, provider); err != nil {
|
||||
t.Fatalf("repeat error = %v", err)
|
||||
}
|
||||
if got := strings.Count(repeatOutput.String(), "action=skip_same"); got != 4 {
|
||||
t.Fatalf("repeat output = %q, skip_same count = %d, want 4", repeatOutput.String(), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
|
||||
localSourceRoot := t.TempDir()
|
||||
writeSourceBundle(t, localSourceRoot, "bundle", testBundleOptions{})
|
||||
s3Destination := fake.New()
|
||||
sshDestination := fake.New()
|
||||
mustWriteFake(t, s3Destination, "bundle/old.txt", "old")
|
||||
mustWriteFake(t, s3Destination, "bundle-sibling/keep.txt", "keep")
|
||||
mustWriteFake(t, sshDestination, "bundle/old.txt", "old")
|
||||
mustWriteFake(t, sshDestination, "bundle-sibling/keep.txt", "keep")
|
||||
cfg := config.Config{Pipelines: []config.Pipeline{{
|
||||
ID: "reports",
|
||||
Source: config.Backend{Backend: config.BackendLocal, Path: localSourceRoot},
|
||||
Destinations: []config.Destination{
|
||||
{
|
||||
ID: "object-archive",
|
||||
Backend: config.BackendS3,
|
||||
Endpoint: "http://s3.test",
|
||||
Bucket: "destination-bucket",
|
||||
},
|
||||
{
|
||||
ID: "ssh-archive",
|
||||
Backend: config.BackendSSH,
|
||||
Host: "ssh.test",
|
||||
Path: "/destination",
|
||||
},
|
||||
},
|
||||
}}}
|
||||
config.ApplyDefaults(&cfg)
|
||||
provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{
|
||||
"s3:destination-bucket": s3Destination,
|
||||
"ssh:/destination": sshDestination,
|
||||
})
|
||||
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{Force: true}, provider); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertFakeFile(t, s3Destination, "bundle/report.md", "# Report\nSunny.\n")
|
||||
assertFakeMissing(t, s3Destination, "bundle/old.txt")
|
||||
assertFakeFile(t, s3Destination, "bundle-sibling/keep.txt", "keep")
|
||||
assertFakeFile(t, sshDestination, "bundle/report.md", "# Report\nSunny.\n")
|
||||
assertFakeMissing(t, sshDestination, "bundle/old.txt")
|
||||
assertFakeFile(t, sshDestination, "bundle-sibling/keep.txt", "keep")
|
||||
}
|
||||
|
||||
func TestRunDryRunDoesNotWrite(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if entries, err := os.ReadDir(destinationRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("destination entries = %v err=%v, want empty", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
type testBundleOptions struct {
|
||||
ID string
|
||||
Created time.Time
|
||||
ExtraFiles []testFile
|
||||
}
|
||||
|
||||
type testFile struct {
|
||||
Path string
|
||||
Data string
|
||||
}
|
||||
|
||||
func writeSourceBundle(t *testing.T, root, relative string, opts testBundleOptions) bundle.Manifest {
|
||||
t.Helper()
|
||||
extraFiles := make([]testutil.SourceFile, 0, len(opts.ExtraFiles))
|
||||
for _, file := range opts.ExtraFiles {
|
||||
extraFiles = append(extraFiles, testutil.SourceFile{Path: file.Path, Data: file.Data})
|
||||
}
|
||||
return testutil.WriteSourceBundle(t, root, relative, testutil.BundleOptions{
|
||||
ID: opts.ID,
|
||||
Created: opts.Created,
|
||||
ExtraFiles: extraFiles,
|
||||
})
|
||||
}
|
||||
|
||||
func writeLocalConfig(t *testing.T, sourceRoot, destinationRoot string) string {
|
||||
t.Helper()
|
||||
return testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
}
|
||||
|
||||
func writeLocalConfigWithPolicy(t *testing.T, sourceRoot, destinationRoot string, publishSource, publishHTML bool) string {
|
||||
t.Helper()
|
||||
transformConfig := ""
|
||||
if publishHTML {
|
||||
transformConfig = `
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar`
|
||||
}
|
||||
return writeConfigFile(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: `+sourceRoot+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: `+destinationRoot+`
|
||||
publish:
|
||||
source: `+fmt.Sprintf("%t", publishSource)+`
|
||||
html: `+fmt.Sprintf("%t", publishHTML)+transformConfig+`
|
||||
`)
|
||||
}
|
||||
|
||||
func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestination string) string {
|
||||
t.Helper()
|
||||
return testutil.WriteFanoutLocalConfig(t, sourceRoot, firstDestination, secondDestination)
|
||||
}
|
||||
|
||||
func writeMixedPolicyFanoutConfig(t *testing.T, sourceRoot, archiveDestination, htmlDestination string) string {
|
||||
t.Helper()
|
||||
return writeConfigFile(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: `+sourceRoot+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: `+archiveDestination+`
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
- id: html
|
||||
backend: local
|
||||
path: `+htmlDestination+`
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
`)
|
||||
}
|
||||
|
||||
func writeConfigFile(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(strings.TrimSpace(body)+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeDestinationState(t *testing.T, root, relative string, manifest bundle.Manifest) {
|
||||
t.Helper()
|
||||
testutil.WriteDestinationState(t, root, relative, manifest, testutil.DestinationStateOptions{})
|
||||
}
|
||||
|
||||
func readStateFile(t *testing.T, path string) state.DistributorState {
|
||||
t.Helper()
|
||||
return testutil.ReadDestinationState(t, path)
|
||||
}
|
||||
|
||||
func assertFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file %s: %v", path, err)
|
||||
}
|
||||
if got := string(data); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file %s: %v", path, err)
|
||||
}
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Fatalf("%s = %q, want substring %q", path, data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFakeFile(t *testing.T, backend *fake.Backend, path, want string) {
|
||||
t.Helper()
|
||||
data, err := backend.ReadFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fake file %s: %v", path, err)
|
||||
}
|
||||
if got := string(data); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFakeMissing(t *testing.T, backend *fake.Backend, path string) {
|
||||
t.Helper()
|
||||
if _, err := backend.Stat(context.Background(), path); !storage.IsNotFound(err) {
|
||||
t.Fatalf("fake file %s stat error = %v, want not found", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFake(t *testing.T, backend *fake.Backend, path, data string) {
|
||||
t.Helper()
|
||||
if _, err := backend.WriteFile(context.Background(), path, []byte(data), storage.WriteOptions{}); err != nil {
|
||||
t.Fatalf("write fake file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func crossBackendConfig(localSourceRoot, s3ToLocalDestination, sshToLocalDestination string) config.Config {
|
||||
cfg := config.Config{
|
||||
Pipelines: []config.Pipeline{
|
||||
{
|
||||
ID: "local-to-s3",
|
||||
Source: config.Backend{Backend: config.BackendLocal, Path: localSourceRoot},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "object-archive",
|
||||
Backend: config.BackendS3,
|
||||
Endpoint: "http://s3.test",
|
||||
Bucket: "destination-bucket",
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "s3-to-local",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendS3,
|
||||
Endpoint: "http://s3.test",
|
||||
Bucket: "source-bucket",
|
||||
},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "local-archive",
|
||||
Backend: config.BackendLocal,
|
||||
Path: s3ToLocalDestination,
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "local-to-ssh",
|
||||
Source: config.Backend{Backend: config.BackendLocal, Path: localSourceRoot},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "ssh-archive",
|
||||
Backend: config.BackendSSH,
|
||||
Host: "ssh.test",
|
||||
Path: "/destination",
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "ssh-to-local",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "ssh.test",
|
||||
Path: "/source",
|
||||
},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "local-archive",
|
||||
Backend: config.BackendLocal,
|
||||
Path: sshToLocalDestination,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
config.ApplyDefaults(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func fakeBackendFactoryProvider(t *testing.T, remoteBackends map[string]storage.Backend) backendFactoryProvider {
|
||||
t.Helper()
|
||||
return func(environment config.Environment) *backendFactory {
|
||||
registry := storage.NewRegistry()
|
||||
if err := registry.Register(config.BackendLocal, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return local.New(cfg[storagePathKey])
|
||||
}); err != nil {
|
||||
t.Fatalf("register local backend: %v", err)
|
||||
}
|
||||
if err := registry.Register(config.BackendS3, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backend := remoteBackends["s3:"+cfg[s3BucketKey]]
|
||||
if backend == nil {
|
||||
return nil, fmt.Errorf("missing fake s3 backend for bucket %s", cfg[s3BucketKey])
|
||||
}
|
||||
return backend, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("register s3 backend: %v", err)
|
||||
}
|
||||
if err := registry.Register(config.BackendSSH, func(ctx context.Context, cfg storage.OpenConfig) (storage.Backend, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backend := remoteBackends["ssh:"+cfg[storagePathKey]]
|
||||
if backend == nil {
|
||||
return nil, fmt.Errorf("missing fake ssh backend for path %s", cfg[storagePathKey])
|
||||
}
|
||||
return backend, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("register ssh backend: %v", err)
|
||||
}
|
||||
return &backendFactory{registry: registry, environment: environment}
|
||||
}
|
||||
}
|
||||
|
||||
type recordingNotifier struct {
|
||||
events []notify.Event
|
||||
check func()
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) Notify(ctx context.Context, event notify.Event) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if n.check != nil {
|
||||
n.check()
|
||||
}
|
||||
n.events = append(n.events, event)
|
||||
return nil
|
||||
}
|
||||
12
internal/app/transforms.go
Normal file
12
internal/app/transforms.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform/markdown"
|
||||
)
|
||||
|
||||
func newTransformRegistry() *transform.Registry {
|
||||
registry := transform.NewRegistry()
|
||||
_ = registry.Register(transform.MarkdownToHTML, markdown.New())
|
||||
return registry
|
||||
}
|
||||
32
internal/app/validate.go
Normal file
32
internal/app/validate.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
)
|
||||
|
||||
type ValidateOptions struct {
|
||||
Path string
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
func Validate(ctx context.Context, options ValidateOptions) error {
|
||||
if options.Path == "" {
|
||||
return fmt.Errorf("validate command requires a path")
|
||||
}
|
||||
backend, err := newBackendFactory().openLocalPath(ctx, options.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bundles, err := bundle.Discover(ctx, backend, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if options.Stdout != nil {
|
||||
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s)\n", len(bundles))
|
||||
}
|
||||
return err
|
||||
}
|
||||
39
internal/app/validate_test.go
Normal file
39
internal/app/validate_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateLocalBundle(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
err := Validate(context.Background(), ValidateOptions{
|
||||
Path: filepath.Join("..", "bundle", "testdata", "valid_bundle"),
|
||||
Stdout: &stdout,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if got, want := stdout.String(), "Validated 1 bundle(s)\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExampleSourceBundle(t *testing.T) {
|
||||
err := Validate(context.Background(), ValidateOptions{
|
||||
Path: filepath.Join("..", "..", "examples", "source-bundle"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() example error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresPath(t *testing.T) {
|
||||
err := Validate(context.Background(), ValidateOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a path") {
|
||||
t.Fatalf("Validate() error = %v, want required path", err)
|
||||
}
|
||||
}
|
||||
49
internal/bundle/digest.go
Normal file
49
internal/bundle/digest.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
func ValidateDigest(value string) error {
|
||||
if !digestPattern.MatchString(value) {
|
||||
return fmt.Errorf("must be lowercase sha256:<64 hex>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FileDigest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func BundleDigest(files []ManifestFile) string {
|
||||
canonical := CanonicalFilePayload(files)
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func CanonicalFilePayload(files []ManifestFile) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteByte('[')
|
||||
for index, file := range files {
|
||||
if index > 0 {
|
||||
builder.WriteByte(',')
|
||||
}
|
||||
builder.WriteString(`{"path":`)
|
||||
builder.WriteString(strconv.Quote(file.Path))
|
||||
builder.WriteString(`,"sha256":`)
|
||||
builder.WriteString(strconv.Quote(file.SHA256))
|
||||
builder.WriteString(`,"size":`)
|
||||
builder.WriteString(strconv.FormatInt(file.Size, 10))
|
||||
builder.WriteByte('}')
|
||||
}
|
||||
builder.WriteByte(']')
|
||||
return builder.String()
|
||||
}
|
||||
21
internal/bundle/digest_test.go
Normal file
21
internal/bundle/digest_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalBundleDigestReferenceFixture(t *testing.T) {
|
||||
manifest, err := ParseManifest(readFixture(t, "testdata/valid_bundle/manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest() error = %v", err)
|
||||
}
|
||||
gotPayload := CanonicalFilePayload(manifest.Files)
|
||||
wantPayload := strings.TrimSpace(string(readFixture(t, "testdata/canonical_payload.json")))
|
||||
if gotPayload != wantPayload {
|
||||
t.Fatalf("canonical payload = %q, want %q", gotPayload, wantPayload)
|
||||
}
|
||||
if got, want := BundleDigest(manifest.Files), manifest.Digest; got != want {
|
||||
t.Fatalf("BundleDigest() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
82
internal/bundle/discover.go
Normal file
82
internal/bundle/discover.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func Discover(ctx context.Context, backend storage.Backend, sourceRoot string) ([]Bundle, error) {
|
||||
if err := storage.ValidatePrefix(sourceRoot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := storage.List(ctx, backend, sourceRoot, storage.WalkOptions{Recursive: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var roots []string
|
||||
for _, entry := range entries {
|
||||
if entry.Type == storage.EntryTypeDirectory {
|
||||
continue
|
||||
}
|
||||
if path.Base(entry.Path) == ManifestName {
|
||||
roots = append(roots, path.Dir(entry.Path))
|
||||
}
|
||||
}
|
||||
for index, root := range roots {
|
||||
if root == "." {
|
||||
roots[index] = ""
|
||||
}
|
||||
}
|
||||
sort.Strings(roots)
|
||||
if len(roots) == 0 {
|
||||
return nil, fmt.Errorf("no bundles found under %q", storage.DisplayPath(sourceRoot))
|
||||
}
|
||||
if err := rejectNestedRoots(roots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bundles := make([]Bundle, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
relativeRoot := relativeToSource(sourceRoot, root)
|
||||
sourceBundle, err := validateAt(ctx, backend, root, relativeRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundles = append(bundles, sourceBundle)
|
||||
}
|
||||
return bundles, nil
|
||||
}
|
||||
|
||||
func rejectNestedRoots(roots []string) error {
|
||||
for index, root := range roots {
|
||||
for _, candidate := range roots[index+1:] {
|
||||
if isAncestor(root, candidate) {
|
||||
return fmt.Errorf("nested manifest %q under bundle %q", storage.DisplayPath(candidate), storage.DisplayPath(root))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAncestor(root, candidate string) bool {
|
||||
if root == "" {
|
||||
return candidate != ""
|
||||
}
|
||||
return strings.HasPrefix(candidate, root+"/")
|
||||
}
|
||||
|
||||
func relativeToSource(sourceRoot, bundleRoot string) string {
|
||||
if sourceRoot == "" {
|
||||
return bundleRoot
|
||||
}
|
||||
if bundleRoot == sourceRoot {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(bundleRoot, sourceRoot+"/")
|
||||
}
|
||||
70
internal/bundle/discover_test.go
Normal file
70
internal/bundle/discover_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
)
|
||||
|
||||
func TestDiscoverFindsBundlesInDeterministicOrder(t *testing.T) {
|
||||
backend := fake.New()
|
||||
addBundle(t, backend, "z/daily")
|
||||
addBundle(t, backend, "a/daily")
|
||||
|
||||
bundles, err := Discover(context.Background(), backend, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Discover() error = %v", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, sourceBundle := range bundles {
|
||||
paths = append(paths, sourceBundle.RootRelativePath)
|
||||
}
|
||||
want := []string{"a/daily", "z/daily"}
|
||||
if !reflect.DeepEqual(paths, want) {
|
||||
t.Fatalf("paths = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverFindsRootBundle(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
bundles, err := Discover(context.Background(), backend, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Discover() error = %v", err)
|
||||
}
|
||||
if got, want := len(bundles), 1; got != want {
|
||||
t.Fatalf("bundle count = %d, want %d", got, want)
|
||||
}
|
||||
if bundles[0].RootRelativePath != "" {
|
||||
t.Fatalf("root = %q, want empty", bundles[0].RootRelativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRejectsNestedManifests(t *testing.T) {
|
||||
backend := fake.New()
|
||||
addBundle(t, backend, "daily")
|
||||
addBundle(t, backend, "daily/nested")
|
||||
|
||||
_, err := Discover(context.Background(), backend, "")
|
||||
assertErrorContains(t, err, "nested manifest")
|
||||
}
|
||||
|
||||
func addBundle(t *testing.T, backend *fake.Backend, root string) {
|
||||
t.Helper()
|
||||
writeFakeFile(t, backend, joinTestPath(root, "manifest.json"), string(readFixture(t, "testdata/valid_bundle/manifest.json")))
|
||||
writeFakeFile(t, backend, joinTestPath(root, "report.md"), string(readFixture(t, "testdata/valid_bundle/report.md")))
|
||||
writeFakeFile(t, backend, joinTestPath(root, "summary.txt"), string(readFixture(t, "testdata/valid_bundle/summary.txt")))
|
||||
}
|
||||
|
||||
func joinTestPath(root, file string) string {
|
||||
if root == "" {
|
||||
return file
|
||||
}
|
||||
joined, err := storage.Join(root, file)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return joined
|
||||
}
|
||||
110
internal/bundle/manifest.go
Normal file
110
internal/bundle/manifest.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ManifestName = "manifest.json"
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Digest string `json:"digest"`
|
||||
Created time.Time `json:"created"`
|
||||
Files []ManifestFile `json:"files"`
|
||||
}
|
||||
|
||||
type ManifestFile struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
RootRelativePath string
|
||||
Manifest Manifest
|
||||
}
|
||||
|
||||
type rawManifest struct {
|
||||
SchemaVersion *int `json:"schema_version"`
|
||||
ID *string `json:"id"`
|
||||
Digest *string `json:"digest"`
|
||||
Created *string `json:"created"`
|
||||
Files []rawManifestFile `json:"files"`
|
||||
}
|
||||
|
||||
type rawManifestFile struct {
|
||||
Path *string `json:"path"`
|
||||
SHA256 *string `json:"sha256"`
|
||||
Size *int64 `json:"size"`
|
||||
}
|
||||
|
||||
func ParseManifest(data []byte) (Manifest, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
var raw rawManifest
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
return Manifest{}, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return Manifest{}, fmt.Errorf("parse manifest: trailing data")
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if raw.SchemaVersion == nil {
|
||||
return Manifest{}, fmt.Errorf("manifest schema_version is required")
|
||||
}
|
||||
manifest.SchemaVersion = *raw.SchemaVersion
|
||||
if raw.ID == nil || *raw.ID == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest id is required")
|
||||
}
|
||||
manifest.ID = *raw.ID
|
||||
if raw.Digest == nil || *raw.Digest == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest digest is required")
|
||||
}
|
||||
manifest.Digest = *raw.Digest
|
||||
if raw.Created == nil || *raw.Created == "" {
|
||||
return Manifest{}, fmt.Errorf("manifest created is required")
|
||||
}
|
||||
created, err := time.Parse(time.RFC3339, *raw.Created)
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("manifest created must be RFC3339: %w", err)
|
||||
}
|
||||
manifest.Created = created
|
||||
if len(raw.Files) == 0 {
|
||||
return Manifest{}, fmt.Errorf("manifest files is required")
|
||||
}
|
||||
|
||||
for index, rawFile := range raw.Files {
|
||||
file, err := parseManifestFile(index, rawFile)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
manifest.Files = append(manifest.Files, file)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return Manifest{}, fmt.Errorf("manifest %w", err)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func parseManifestFile(index int, raw rawManifestFile) (ManifestFile, error) {
|
||||
if raw.Path == nil || *raw.Path == "" {
|
||||
return ManifestFile{}, fmt.Errorf("manifest files[%d].path is required", index)
|
||||
}
|
||||
if raw.SHA256 == nil || *raw.SHA256 == "" {
|
||||
return ManifestFile{}, fmt.Errorf("manifest files[%d].sha256 is required", index)
|
||||
}
|
||||
if raw.Size == nil {
|
||||
return ManifestFile{}, fmt.Errorf("manifest files[%d].size is required", index)
|
||||
}
|
||||
return ManifestFile{
|
||||
Path: *raw.Path,
|
||||
SHA256: *raw.SHA256,
|
||||
Size: *raw.Size,
|
||||
}, nil
|
||||
}
|
||||
191
internal/bundle/manifest_test.go
Normal file
191
internal/bundle/manifest_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func TestParseManifestValid(t *testing.T) {
|
||||
data := readFixture(t, "testdata/valid_bundle/manifest.json")
|
||||
manifest, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest() error = %v", err)
|
||||
}
|
||||
if manifest.SchemaVersion != 1 {
|
||||
t.Fatalf("schema version = %d, want 1", manifest.SchemaVersion)
|
||||
}
|
||||
if manifest.ID != "weather.daily.brentwood.2026-05-30" {
|
||||
t.Fatalf("id = %q", manifest.ID)
|
||||
}
|
||||
if got, want := len(manifest.Files), 2; got != want {
|
||||
t.Fatalf("file count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsInvalidJSON(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"schema_version":`))
|
||||
assertErrorContains(t, err, "parse manifest")
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsMissingRequiredFields(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"schema_version": `{"id":"id","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","created":"2026-05-30T11:10:00Z","files":[{"path":"report.md","sha256":"sha256:0000000000000000000000000000000000000000000000000000000000000000","size":0}]}`,
|
||||
"id": `{"schema_version":1,"digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","created":"2026-05-30T11:10:00Z","files":[{"path":"report.md","sha256":"sha256:0000000000000000000000000000000000000000000000000000000000000000","size":0}]}`,
|
||||
"digest": `{"schema_version":1,"id":"id","created":"2026-05-30T11:10:00Z","files":[{"path":"report.md","sha256":"sha256:0000000000000000000000000000000000000000000000000000000000000000","size":0}]}`,
|
||||
"created": `{"schema_version":1,"id":"id","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","files":[{"path":"report.md","sha256":"sha256:0000000000000000000000000000000000000000000000000000000000000000","size":0}]}`,
|
||||
"files": `{"schema_version":1,"id":"id","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","created":"2026-05-30T11:10:00Z"}`,
|
||||
"file path": `{"schema_version":1,"id":"id","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","created":"2026-05-30T11:10:00Z","files":[{"sha256":"sha256:0000000000000000000000000000000000000000000000000000000000000000","size":0}]}`,
|
||||
"file digest": `{"schema_version":1,"id":"id","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","created":"2026-05-30T11:10:00Z","files":[{"path":"report.md","size":0}]}`,
|
||||
"file size": `{"schema_version":1,"id":"id","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","created":"2026-05-30T11:10:00Z","files":[{"path":"report.md","sha256":"sha256:0000000000000000000000000000000000000000000000000000000000000000"}]}`,
|
||||
}
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(body))
|
||||
assertErrorContains(t, err, "required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsInvalidSchemaVersion(t *testing.T) {
|
||||
data := replaceFixture(t, `"schema_version": 1`, `"schema_version": 2`)
|
||||
_, err := ParseManifest(data)
|
||||
assertErrorContains(t, err, "schema_version must be 1")
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsInvalidTimestamp(t *testing.T) {
|
||||
data := replaceFixture(t, `"created": "2026-05-30T11:10:00Z"`, `"created": "May 30"`)
|
||||
_, err := ParseManifest(data)
|
||||
assertErrorContains(t, err, "RFC3339")
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsInvalidDigestFormat(t *testing.T) {
|
||||
data := replaceFixture(t, `"digest": "sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"`, `"digest": "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"`)
|
||||
_, err := ParseManifest(data)
|
||||
assertErrorContains(t, err, "lowercase")
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsUnsafeFilePaths(t *testing.T) {
|
||||
tests := []string{
|
||||
`"path": "../report.md"`,
|
||||
`"path": "/report.md"`,
|
||||
`"path": "nested/../report.md"`,
|
||||
`"path": "manifest.json"`,
|
||||
`"path": "` + storage.StateFileName + `"`,
|
||||
}
|
||||
for _, replacement := range tests {
|
||||
t.Run(replacement, func(t *testing.T) {
|
||||
data := replaceFixture(t, `"path": "report.md"`, replacement)
|
||||
_, err := ParseManifest(data)
|
||||
if err == nil {
|
||||
t.Fatal("ParseManifest() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsDuplicatePaths(t *testing.T) {
|
||||
data := replaceFixture(t, `"path": "summary.txt"`, `"path": "report.md"`)
|
||||
_, err := ParseManifest(data)
|
||||
assertErrorContains(t, err, "duplicates")
|
||||
}
|
||||
|
||||
func TestValidateManifestAcceptsValidFixture(t *testing.T) {
|
||||
manifest := validFixtureManifest(t)
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
t.Fatalf("ValidateManifest() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestRejectsInvalidManifest(t *testing.T) {
|
||||
tests := map[string]func(Manifest) Manifest{
|
||||
"schema version": func(manifest Manifest) Manifest {
|
||||
manifest.SchemaVersion = 2
|
||||
return manifest
|
||||
},
|
||||
"empty id": func(manifest Manifest) Manifest {
|
||||
manifest.ID = ""
|
||||
return manifest
|
||||
},
|
||||
"bad digest": func(manifest Manifest) Manifest {
|
||||
manifest.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"
|
||||
return manifest
|
||||
},
|
||||
"zero created": func(manifest Manifest) Manifest {
|
||||
manifest.Created = time.Time{}
|
||||
return manifest
|
||||
},
|
||||
"empty files": func(manifest Manifest) Manifest {
|
||||
manifest.Files = nil
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"unsafe path": func(manifest Manifest) Manifest {
|
||||
manifest.Files[0].Path = "../report.md"
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"duplicate path": func(manifest Manifest) Manifest {
|
||||
manifest.Files[1].Path = manifest.Files[0].Path
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"negative size": func(manifest Manifest) Manifest {
|
||||
manifest.Files[0].Size = -1
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
return manifest
|
||||
},
|
||||
"digest mismatch": func(manifest Manifest) Manifest {
|
||||
manifest.Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
return manifest
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := ValidateManifest(mutate(validFixtureManifest(t)))
|
||||
if err == nil {
|
||||
t.Fatal("ValidateManifest() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validFixtureManifest(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
manifest, err := ParseManifest(readFixture(t, "testdata/valid_bundle/manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest() error = %v", err)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
func readFixture(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func replaceFixture(t *testing.T, old, replacement string) []byte {
|
||||
t.Helper()
|
||||
body := string(readFixture(t, "testdata/valid_bundle/manifest.json"))
|
||||
if !strings.Contains(body, old) {
|
||||
t.Fatalf("fixture does not contain %q", old)
|
||||
}
|
||||
return []byte(strings.Replace(body, old, replacement, 1))
|
||||
}
|
||||
|
||||
func assertErrorContains(t *testing.T, err error, want string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("error = nil, want substring %q", want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
1
internal/bundle/testdata/canonical_payload.json
vendored
Normal file
1
internal/bundle/testdata/canonical_payload.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[{"path":"report.md","sha256":"sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6","size":16},{"path":"summary.txt","sha256":"sha256:3cbb36aca330b3bd113955dfbada0adb7a5f95ad9f678bd61f175406c6a37e95","size":8}]
|
||||
18
internal/bundle/testdata/valid_bundle/manifest.json
vendored
Normal file
18
internal/bundle/testdata/valid_bundle/manifest.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe",
|
||||
"created": "2026-05-30T11:10:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "report.md",
|
||||
"sha256": "sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6",
|
||||
"size": 16
|
||||
},
|
||||
{
|
||||
"path": "summary.txt",
|
||||
"sha256": "sha256:3cbb36aca330b3bd113955dfbada0adb7a5f95ad9f678bd61f175406c6a37e95",
|
||||
"size": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
2
internal/bundle/testdata/valid_bundle/report.md
vendored
Normal file
2
internal/bundle/testdata/valid_bundle/report.md
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Report
|
||||
Sunny.
|
||||
1
internal/bundle/testdata/valid_bundle/summary.txt
vendored
Normal file
1
internal/bundle/testdata/valid_bundle/summary.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Summary
|
||||
116
internal/bundle/validate.go
Normal file
116
internal/bundle/validate.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func ValidateSourcePath(path string) error {
|
||||
if err := storage.ValidatePath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
switch path {
|
||||
case ManifestName, storage.StateFileName:
|
||||
return fmt.Errorf("%q is reserved", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateManifest(manifest Manifest) error {
|
||||
if manifest.SchemaVersion != 1 {
|
||||
return fmt.Errorf("schema_version must be 1")
|
||||
}
|
||||
if manifest.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
if err := ValidateDigest(manifest.Digest); err != nil {
|
||||
return fmt.Errorf("digest: %w", err)
|
||||
}
|
||||
if manifest.Created.IsZero() {
|
||||
return fmt.Errorf("created is required")
|
||||
}
|
||||
if len(manifest.Files) == 0 {
|
||||
return fmt.Errorf("files is required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(manifest.Files))
|
||||
for index, file := range manifest.Files {
|
||||
if err := ValidateSourcePath(file.Path); err != nil {
|
||||
return fmt.Errorf("files[%d].path: %w", index, err)
|
||||
}
|
||||
if err := ValidateDigest(file.SHA256); err != nil {
|
||||
return fmt.Errorf("files[%d].sha256: %w", index, err)
|
||||
}
|
||||
if file.Size < 0 {
|
||||
return fmt.Errorf("files[%d].size must be non-negative", index)
|
||||
}
|
||||
if _, exists := seen[file.Path]; exists {
|
||||
return fmt.Errorf("files[%d].path duplicates %q", index, file.Path)
|
||||
}
|
||||
seen[file.Path] = struct{}{}
|
||||
}
|
||||
if actual := BundleDigest(manifest.Files); actual != manifest.Digest {
|
||||
return fmt.Errorf("digest mismatch: got %s want %s", actual, manifest.Digest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Validate(ctx context.Context, backend storage.Backend, bundleRoot string) (Bundle, error) {
|
||||
return validateAt(ctx, backend, bundleRoot, bundleRoot)
|
||||
}
|
||||
|
||||
func validateAt(ctx context.Context, backend storage.Backend, bundleRoot, relativeRoot string) (Bundle, error) {
|
||||
if err := storage.ValidatePrefix(bundleRoot); err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
manifestPath, err := storage.Join(bundleRoot, ManifestName)
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
manifestData, err := backend.ReadFile(ctx, manifestPath)
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("read manifest %q: %w", manifestPath, err)
|
||||
}
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("bundle %q: %w", storage.DisplayPath(relativeRoot), err)
|
||||
}
|
||||
|
||||
for index, manifestFile := range manifest.Files {
|
||||
filePath, err := storage.Join(bundleRoot, manifestFile.Path)
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("bundle %q file %q: %w", storage.DisplayPath(relativeRoot), manifestFile.Path, err)
|
||||
}
|
||||
entry, err := backend.Stat(ctx, filePath)
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("bundle %q file %q stat: %w", storage.DisplayPath(relativeRoot), manifestFile.Path, err)
|
||||
}
|
||||
if entry.Type != storage.EntryTypeFile {
|
||||
return Bundle{}, fmt.Errorf("bundle %q file %q must be a regular file", storage.DisplayPath(relativeRoot), manifestFile.Path)
|
||||
}
|
||||
if entry.Size != manifestFile.Size {
|
||||
return Bundle{}, fmt.Errorf("bundle %q file %q size mismatch: got %d want %d", storage.DisplayPath(relativeRoot), manifestFile.Path, entry.Size, manifestFile.Size)
|
||||
}
|
||||
data, err := backend.ReadFile(ctx, filePath)
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("bundle %q file %q read: %w", storage.DisplayPath(relativeRoot), manifestFile.Path, err)
|
||||
}
|
||||
actualDigest := FileDigest(data)
|
||||
if actualDigest != manifestFile.SHA256 {
|
||||
return Bundle{}, fmt.Errorf("bundle %q file %q sha256 mismatch: got %s want %s", storage.DisplayPath(relativeRoot), manifestFile.Path, actualDigest, manifestFile.SHA256)
|
||||
}
|
||||
manifest.Files[index].SHA256 = actualDigest
|
||||
manifest.Files[index].Size = int64(len(data))
|
||||
}
|
||||
|
||||
actualBundleDigest := BundleDigest(manifest.Files)
|
||||
if actualBundleDigest != manifest.Digest {
|
||||
return Bundle{}, fmt.Errorf("bundle %q digest mismatch: got %s want %s", storage.DisplayPath(relativeRoot), actualBundleDigest, manifest.Digest)
|
||||
}
|
||||
|
||||
return Bundle{
|
||||
RootRelativePath: relativeRoot,
|
||||
Manifest: manifest,
|
||||
}, nil
|
||||
}
|
||||
100
internal/bundle/validate_test.go
Normal file
100
internal/bundle/validate_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
)
|
||||
|
||||
func TestValidateValidBundle(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
sourceBundle, err := Validate(context.Background(), backend, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if sourceBundle.RootRelativePath != "" {
|
||||
t.Fatalf("root = %q, want empty", sourceBundle.RootRelativePath)
|
||||
}
|
||||
if sourceBundle.Manifest.ID != "weather.daily.brentwood.2026-05-30" {
|
||||
t.Fatalf("id = %q", sourceBundle.Manifest.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMissingFile(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
deleteFakeFile(t, backend, "summary.txt")
|
||||
_, err := Validate(context.Background(), backend, "")
|
||||
assertErrorContains(t, err, "stat")
|
||||
}
|
||||
|
||||
func TestValidateRejectsSizeMismatch(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
manifest := validFixtureManifest(t)
|
||||
manifest.Files[1].Size = 9
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
writeManifest(t, backend, manifest)
|
||||
_, err := Validate(context.Background(), backend, "")
|
||||
assertErrorContains(t, err, "size mismatch")
|
||||
}
|
||||
|
||||
func TestValidateRejectsPerFileDigestMismatch(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
writeFakeFile(t, backend, "report.md", "# Report\nCloud.\n")
|
||||
_, err := Validate(context.Background(), backend, "")
|
||||
assertErrorContains(t, err, "sha256 mismatch")
|
||||
}
|
||||
|
||||
func TestValidateRejectsBundleDigestMismatch(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
manifest := strings.Replace(string(readFixture(t, "testdata/valid_bundle/manifest.json")), `"digest": "sha256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"`, `"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000"`, 1)
|
||||
writeFakeFile(t, backend, "manifest.json", manifest)
|
||||
_, err := Validate(context.Background(), backend, "")
|
||||
assertErrorContains(t, err, "digest mismatch")
|
||||
}
|
||||
|
||||
func TestValidateRejectsSymlinkFile(t *testing.T) {
|
||||
backend := validFakeBundle(t)
|
||||
if err := backend.AddSymlink("summary.txt"); err != nil {
|
||||
t.Fatalf("AddSymlink() error = %v", err)
|
||||
}
|
||||
_, err := Validate(context.Background(), backend, "")
|
||||
assertErrorContains(t, err, "regular file")
|
||||
}
|
||||
|
||||
func validFakeBundle(t *testing.T) *fake.Backend {
|
||||
t.Helper()
|
||||
backend := fake.New()
|
||||
writeFakeFile(t, backend, "manifest.json", string(readFixture(t, "testdata/valid_bundle/manifest.json")))
|
||||
writeFakeFile(t, backend, "report.md", string(readFixture(t, "testdata/valid_bundle/report.md")))
|
||||
writeFakeFile(t, backend, "summary.txt", string(readFixture(t, "testdata/valid_bundle/summary.txt")))
|
||||
return backend
|
||||
}
|
||||
|
||||
func writeFakeFile(t *testing.T, backend *fake.Backend, path, data string) {
|
||||
t.Helper()
|
||||
_, err := backend.WriteFile(context.Background(), path, []byte(data), storage.WriteOptions{Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, backend *fake.Backend, manifest Manifest) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalIndent() error = %v", err)
|
||||
}
|
||||
writeFakeFile(t, backend, ManifestName, string(append(data, '\n')))
|
||||
}
|
||||
|
||||
func deleteFakeFile(t *testing.T, backend *fake.Backend, path string) {
|
||||
t.Helper()
|
||||
err := backend.DeleteManagedBundle(context.Background(), "", []string{path}, storage.DeleteOptions{IgnoreMissing: true})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteManagedBundle(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
32
internal/cli/inspect.go
Normal file
32
internal/cli/inspect.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if hasHelp(args) {
|
||||
printInspectHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
path, ok := parseOptionalPathArg(stderr, "inspect", args)
|
||||
if !ok {
|
||||
return exitUsage
|
||||
}
|
||||
if err := app.Inspect(ctx, app.InspectOptions{Path: path, Stdout: stdout}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func printInspectHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor inspect <path>
|
||||
|
||||
Print a normalized summary of local source bundles.
|
||||
`)
|
||||
}
|
||||
98
internal/cli/root.go
Normal file
98
internal/cli/root.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
const (
|
||||
exitOK = 0
|
||||
exitError = 1
|
||||
exitUsage = 2
|
||||
)
|
||||
|
||||
func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printRootHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
printRootHelp(stdout)
|
||||
return exitOK
|
||||
case "version":
|
||||
return versionCommand(ctx, args[1:], stdout, stderr)
|
||||
case "run":
|
||||
return runCommand(ctx, args[1:], stdout, stderr)
|
||||
case "validate":
|
||||
return validateCommand(ctx, args[1:], stdout, stderr)
|
||||
case "inspect":
|
||||
return inspectCommand(ctx, args[1:], stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "%s: unknown command %q\n\n", app.Name, args[0])
|
||||
printRootHelp(stderr)
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
func printRootHelp(w io.Writer) {
|
||||
fmt.Fprintf(w, `%s validates and publishes manifested report bundles.
|
||||
|
||||
Usage:
|
||||
%s <command> [options]
|
||||
|
||||
Commands:
|
||||
version Print version information
|
||||
run Run configured distribution pipelines
|
||||
validate Validate a source bundle or bundle tree
|
||||
inspect Inspect bundles or distributor state
|
||||
|
||||
Use "%s <command> --help" for command-specific help.
|
||||
`, app.Name, app.Name, app.Name)
|
||||
}
|
||||
|
||||
func hasHelp(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "-h" || arg == "--help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fail(stderr io.Writer, err error) int {
|
||||
fmt.Fprintf(stderr, "%s: %s\n", app.Name, err)
|
||||
return exitError
|
||||
}
|
||||
|
||||
func rejectExtraArgs(stderr io.Writer, command string, args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(stderr, "%s: %s does not accept arguments: %s\n", app.Name, command, strings.Join(args, " "))
|
||||
return true
|
||||
}
|
||||
|
||||
func parseOptionalPathArg(stderr io.Writer, command string, args []string) (string, bool) {
|
||||
if len(args) > 1 {
|
||||
fmt.Fprintf(stderr, "%s: %s accepts at most one path\n", app.Name, command)
|
||||
return "", false
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return "", true
|
||||
}
|
||||
return args[0], true
|
||||
}
|
||||
|
||||
func rejectPositionalArgs(stderr io.Writer, command string, args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(stderr, "%s: %s does not accept positional arguments: %v\n", app.Name, command, args)
|
||||
return true
|
||||
}
|
||||
251
internal/cli/root_test.go
Normal file
251
internal/cli/root_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestExecuteRootHelp(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"--help"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Usage:") {
|
||||
t.Fatalf("stdout = %q, want help text", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteVersion(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"version"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
if got, want := stdout.String(), "distributor dev\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidate(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"validate", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if got, want := stdout.String(), "Validated 1 bundle(s)\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidateArgs(t *testing.T) {
|
||||
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCode int
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "zero args",
|
||||
args: []string{"validate"},
|
||||
wantCode: exitError,
|
||||
wantStderr: "requires a path",
|
||||
},
|
||||
{
|
||||
name: "one arg",
|
||||
args: []string{"validate", validPath},
|
||||
wantCode: exitOK,
|
||||
wantStdout: "Validated 1 bundle(s)",
|
||||
},
|
||||
{
|
||||
name: "two args",
|
||||
args: []string{"validate", validPath, validPath},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "accepts at most one path",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(context.Background(), tt.args, &stdout, &stderr)
|
||||
if code != tt.wantCode {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, tt.wantCode, stderr.String())
|
||||
}
|
||||
if tt.wantStdout != "" && !strings.Contains(stdout.String(), tt.wantStdout) {
|
||||
t.Fatalf("stdout = %q, want substring %q", stdout.String(), tt.wantStdout)
|
||||
}
|
||||
if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInspect(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"inspect", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "id=weather.daily.brentwood.2026-05-30") {
|
||||
t.Fatalf("stdout = %q, want bundle summary", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInspectArgs(t *testing.T) {
|
||||
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCode int
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "zero args",
|
||||
args: []string{"inspect"},
|
||||
wantCode: exitError,
|
||||
wantStderr: "requires a path",
|
||||
},
|
||||
{
|
||||
name: "one arg",
|
||||
args: []string{"inspect", validPath},
|
||||
wantCode: exitOK,
|
||||
wantStdout: "id=weather.daily.brentwood.2026-05-30",
|
||||
},
|
||||
{
|
||||
name: "two args",
|
||||
args: []string{"inspect", validPath, validPath},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "accepts at most one path",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(context.Background(), tt.args, &stdout, &stderr)
|
||||
if code != tt.wantCode {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, tt.wantCode, stderr.String())
|
||||
}
|
||||
if tt.wantStdout != "" && !strings.Contains(stdout.String(), tt.wantStdout) {
|
||||
t.Fatalf("stdout = %q, want substring %q", stdout.String(), tt.wantStdout)
|
||||
}
|
||||
if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunDryRun(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, t.TempDir())
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"run", "--config", configPath, "--dry-run"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=publish_new") {
|
||||
t.Fatalf("stdout = %q, want config summary", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged file: %v", err)
|
||||
}
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(context.Background(), []string{"run", "--config", configPath, "--force", "--dry-run"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=force_replace") {
|
||||
t.Fatalf("stdout = %q, want force_replace", stdout.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); err != nil {
|
||||
t.Fatalf("unmanaged file stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("state stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunRejectsExtraPositionalArgs(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"run", "--config", "config.yml", "extra"}, &stdout, &stderr)
|
||||
|
||||
if code != exitUsage {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "does not accept positional arguments") {
|
||||
t.Fatalf("stderr = %q, want positional argument error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunPublishes(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"run", "--config", configPath}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil {
|
||||
t.Fatalf("state stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommandIsUsageError(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"nope"}, &stdout, &stderr)
|
||||
|
||||
if code != exitUsage {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "unknown command") {
|
||||
t.Fatalf("stderr = %q, want unknown command error", stderr.String())
|
||||
}
|
||||
}
|
||||
53
internal/cli/run.go
Normal file
53
internal/cli/run.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if hasHelp(args) {
|
||||
printRunHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
flags := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
dryRun := flags.Bool("dry-run", false, "load and validate config without publishing")
|
||||
force := flags.Bool("force", false, "allow explicit destructive replacement for supported conflicts")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
if rejectPositionalArgs(stderr, "run", flags.Args()) {
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if err := app.Run(ctx, app.RunOptions{
|
||||
ConfigPath: *configPath,
|
||||
DryRun: *dryRun,
|
||||
Force: *force,
|
||||
Stdout: stdout,
|
||||
}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func printRunHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor run --config <path> [--dry-run] [--force]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config file
|
||||
--dry-run Load and validate config without publishing
|
||||
--force Allow explicit destructive replacement for supported conflicts
|
||||
|
||||
Run discovers configured source bundles, plans each destination, publishes
|
||||
selected outputs unless --dry-run is set, and prints a final status summary.
|
||||
`)
|
||||
}
|
||||
32
internal/cli/validate.go
Normal file
32
internal/cli/validate.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
func validateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if hasHelp(args) {
|
||||
printValidateHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
path, ok := parseOptionalPathArg(stderr, "validate", args)
|
||||
if !ok {
|
||||
return exitUsage
|
||||
}
|
||||
if err := app.Validate(ctx, app.ValidateOptions{Path: path, Stdout: stdout}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func printValidateHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor validate <path>
|
||||
|
||||
Validate a local source bundle directory or a tree containing source bundles.
|
||||
`)
|
||||
}
|
||||
29
internal/cli/version.go
Normal file
29
internal/cli/version.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
func versionCommand(_ context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if hasHelp(args) {
|
||||
printVersionHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
if rejectExtraArgs(stderr, "version", args) {
|
||||
return exitUsage
|
||||
}
|
||||
fmt.Fprintln(stdout, app.VersionString())
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func printVersionHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor version
|
||||
|
||||
Print version information.
|
||||
`)
|
||||
}
|
||||
87
internal/config/config.go
Normal file
87
internal/config/config.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
Secrets Secrets `yaml:"secrets"`
|
||||
Pipelines []Pipeline `yaml:"pipelines"`
|
||||
}
|
||||
|
||||
type Secrets struct {
|
||||
Directory string `yaml:"directory"`
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
ID string `yaml:"id"`
|
||||
Source Backend `yaml:"source"`
|
||||
Validation ValidationPolicy `yaml:"validation"`
|
||||
Destinations []Destination `yaml:"destinations"`
|
||||
}
|
||||
|
||||
type Destination struct {
|
||||
ID string `yaml:"id"`
|
||||
Backend string `yaml:"backend"`
|
||||
Host string `yaml:"host"`
|
||||
User string `yaml:"user"`
|
||||
Port int `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Prefix string `yaml:"prefix"`
|
||||
Region string `yaml:"region"`
|
||||
ForcePath *bool `yaml:"force_path_style"`
|
||||
Creds Credentials `yaml:"credentials"`
|
||||
SSH SSH `yaml:",inline"`
|
||||
Publish *PublishPolicy `yaml:"publish"`
|
||||
Transform Transform `yaml:"transform"`
|
||||
Transfer TransferPolicy `yaml:"transfer"`
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
Backend string `yaml:"backend"`
|
||||
Host string `yaml:"host"`
|
||||
User string `yaml:"user"`
|
||||
Port int `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Prefix string `yaml:"prefix"`
|
||||
Region string `yaml:"region"`
|
||||
ForcePath *bool `yaml:"force_path_style"`
|
||||
Creds Credentials `yaml:"credentials"`
|
||||
SSH SSH `yaml:",inline"`
|
||||
}
|
||||
|
||||
type SSH struct {
|
||||
KeyFile string `yaml:"ssh_key_file"`
|
||||
KnownHosts string `yaml:"known_hosts"`
|
||||
HostKeyPolicy HostKeyPolicy `yaml:"host_key_policy"`
|
||||
}
|
||||
|
||||
type Credentials struct {
|
||||
AccessKeyIDEnv string `yaml:"access_key_id_env"`
|
||||
SecretAccessKeyEnv string `yaml:"secret_access_key_env"`
|
||||
}
|
||||
|
||||
type ValidationPolicy struct {
|
||||
OnDigestMismatch string `yaml:"on_digest_mismatch"`
|
||||
}
|
||||
|
||||
type PublishPolicy struct {
|
||||
Source bool `yaml:"source"`
|
||||
HTML bool `yaml:"html"`
|
||||
}
|
||||
|
||||
type Transform struct {
|
||||
MarkdownToHTML *MarkdownToHTML `yaml:"markdown_to_html"`
|
||||
}
|
||||
|
||||
type MarkdownToHTML struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Mode string `yaml:"mode"`
|
||||
}
|
||||
|
||||
type TransferPolicy struct {
|
||||
OnDestinationSame string `yaml:"on_destination_same"`
|
||||
OnDestinationOlder string `yaml:"on_destination_older"`
|
||||
OnDestinationNewer string `yaml:"on_destination_newer"`
|
||||
OnConflict string `yaml:"on_conflict"`
|
||||
}
|
||||
93
internal/config/defaults.go
Normal file
93
internal/config/defaults.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
const DefaultConfigPath = "/usr/local/etc/distributor/config.yml"
|
||||
|
||||
const (
|
||||
BackendLocal = "local"
|
||||
BackendSSH = "ssh"
|
||||
BackendS3 = "s3"
|
||||
)
|
||||
|
||||
const (
|
||||
ValidationActionFail = "fail"
|
||||
)
|
||||
|
||||
const (
|
||||
TransferActionSkip = "skip"
|
||||
TransferActionReplace = "replace"
|
||||
TransferActionFail = "fail"
|
||||
)
|
||||
|
||||
const (
|
||||
TransformModeSidecar = "sidecar"
|
||||
)
|
||||
|
||||
const DefaultS3Region = "us-east-1"
|
||||
|
||||
func ApplyDefaults(cfg *Config) {
|
||||
for pipelineIndex := range cfg.Pipelines {
|
||||
pipeline := &cfg.Pipelines[pipelineIndex]
|
||||
applyBackendDefaults(&pipeline.Source)
|
||||
if pipeline.Validation.OnDigestMismatch == "" {
|
||||
pipeline.Validation.OnDigestMismatch = ValidationActionFail
|
||||
}
|
||||
for destinationIndex := range pipeline.Destinations {
|
||||
destination := &pipeline.Destinations[destinationIndex]
|
||||
applyDestinationDefaults(destination)
|
||||
if destination.Publish == nil {
|
||||
destination.Publish = &PublishPolicy{Source: true}
|
||||
}
|
||||
if destination.Transfer.OnDestinationSame == "" {
|
||||
destination.Transfer.OnDestinationSame = TransferActionSkip
|
||||
}
|
||||
if destination.Transfer.OnDestinationOlder == "" {
|
||||
destination.Transfer.OnDestinationOlder = TransferActionReplace
|
||||
}
|
||||
if destination.Transfer.OnDestinationNewer == "" {
|
||||
destination.Transfer.OnDestinationNewer = TransferActionSkip
|
||||
}
|
||||
if destination.Transfer.OnConflict == "" {
|
||||
destination.Transfer.OnConflict = TransferActionFail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyBackendDefaults(backend *Backend) {
|
||||
if backend.Backend == BackendSSH {
|
||||
if backend.Port == 0 {
|
||||
backend.Port = 22
|
||||
}
|
||||
if backend.SSH.HostKeyPolicy == "" {
|
||||
backend.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
|
||||
}
|
||||
}
|
||||
if backend.Backend == BackendS3 {
|
||||
applyS3Defaults(&backend.Region, &backend.Prefix, &backend.ForcePath)
|
||||
}
|
||||
}
|
||||
|
||||
func applyDestinationDefaults(destination *Destination) {
|
||||
if destination.Backend == BackendSSH {
|
||||
if destination.Port == 0 {
|
||||
destination.Port = 22
|
||||
}
|
||||
if destination.SSH.HostKeyPolicy == "" {
|
||||
destination.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
|
||||
}
|
||||
}
|
||||
if destination.Backend == BackendS3 {
|
||||
applyS3Defaults(&destination.Region, &destination.Prefix, &destination.ForcePath)
|
||||
}
|
||||
}
|
||||
|
||||
func applyS3Defaults(region, prefix *string, forcePath **bool) {
|
||||
if *region == "" {
|
||||
*region = DefaultS3Region
|
||||
}
|
||||
*prefix = NormalizeS3Prefix(*prefix)
|
||||
if *forcePath == nil {
|
||||
defaultForcePath := true
|
||||
*forcePath = &defaultForcePath
|
||||
}
|
||||
}
|
||||
29
internal/config/load.go
Normal file
29
internal/config/load.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func LoadFile(path string) (Config, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load config %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var cfg Config
|
||||
decoder := yaml.NewDecoder(file)
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config %q: %w", path, err)
|
||||
}
|
||||
|
||||
ApplyDefaults(&cfg)
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("validate config %q: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
508
internal/config/load_test.go
Normal file
508
internal/config/load_test.go
Normal file
@@ -0,0 +1,508 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadFileValidMinimalLocalToLocalConfig(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: local-copy
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/reports
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/archive
|
||||
`)
|
||||
|
||||
if got, want := len(cfg.Pipelines), 1; got != want {
|
||||
t.Fatalf("pipeline count = %d, want %d", got, want)
|
||||
}
|
||||
destination := cfg.Pipelines[0].Destinations[0]
|
||||
if !destination.Publish.Source || destination.Publish.HTML {
|
||||
t.Fatalf("publish defaults = source:%t html:%t, want source:true html:false", destination.Publish.Source, destination.Publish.HTML)
|
||||
}
|
||||
if got, want := cfg.Pipelines[0].Validation.OnDigestMismatch, ValidationActionFail; got != want {
|
||||
t.Fatalf("validation default = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := destination.Transfer.OnDestinationOlder, TransferActionReplace; got != want {
|
||||
t.Fatalf("transfer default = %q, want %q", got, want)
|
||||
}
|
||||
if cfg.Secrets.Directory != "" {
|
||||
t.Fatalf("secrets.directory = %q, want empty", cfg.Secrets.Directory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileValidSecretsDirectoryConfig(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
secrets:
|
||||
directory: /run/secrets/distributor
|
||||
pipelines:
|
||||
- id: local-copy
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/reports
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/archive
|
||||
`)
|
||||
|
||||
if got, want := cfg.Secrets.Directory, "/run/secrets/distributor"; got != want {
|
||||
t.Fatalf("secrets.directory = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileValidFanOutConfig(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: fan-out
|
||||
source:
|
||||
backend: local
|
||||
path: /var/spool/reports
|
||||
destinations:
|
||||
- id: markdown-archive
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: archive
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
- id: static-site
|
||||
backend: ssh
|
||||
host: example.com
|
||||
user: deploy
|
||||
port: 22
|
||||
path: /srv/www/reports
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
`)
|
||||
|
||||
if got, want := len(cfg.Pipelines[0].Destinations), 2; got != want {
|
||||
t.Fatalf("destination count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileValidBackendConfigs(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"local": `
|
||||
pipelines:
|
||||
- id: local-backend
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: local-destination
|
||||
backend: local
|
||||
path: /destination
|
||||
`,
|
||||
"ssh": `
|
||||
pipelines:
|
||||
- id: ssh-backend
|
||||
source:
|
||||
backend: ssh
|
||||
host: source.example.com
|
||||
user: reports
|
||||
path: /source
|
||||
destinations:
|
||||
- id: ssh-destination
|
||||
backend: ssh
|
||||
host: destination.example.com
|
||||
user: deploy
|
||||
port: 2222
|
||||
path: /destination
|
||||
ssh_key_file: /home/deploy/.ssh/id_ed25519
|
||||
known_hosts: /home/deploy/.ssh/known_hosts
|
||||
host_key_policy: strict
|
||||
`,
|
||||
"s3": `
|
||||
pipelines:
|
||||
- id: s3-backend
|
||||
source:
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: source
|
||||
prefix: incoming
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
destinations:
|
||||
- id: s3-destination
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: destination
|
||||
prefix: archive
|
||||
`,
|
||||
}
|
||||
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
loadConfig(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileDefaultsS3Config(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: s3-defaults
|
||||
source:
|
||||
backend: s3
|
||||
endpoint: http://127.0.0.1:9000
|
||||
bucket: source
|
||||
prefix: /incoming/reports/
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: s3
|
||||
endpoint: http://127.0.0.1:9000
|
||||
bucket: destination
|
||||
`)
|
||||
|
||||
source := cfg.Pipelines[0].Source
|
||||
if got, want := source.Region, DefaultS3Region; got != want {
|
||||
t.Fatalf("source region = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.Prefix, "incoming/reports"; got != want {
|
||||
t.Fatalf("source prefix = %q, want %q", got, want)
|
||||
}
|
||||
if !ForcePathStyle(source.ForcePath) {
|
||||
t.Fatal("source force_path_style = false, want true")
|
||||
}
|
||||
destination := cfg.Pipelines[0].Destinations[0]
|
||||
if got, want := destination.Region, DefaultS3Region; got != want {
|
||||
t.Fatalf("destination region = %q, want %q", got, want)
|
||||
}
|
||||
if !ForcePathStyle(destination.ForcePath) {
|
||||
t.Fatal("destination force_path_style = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFilePreservesExplicitS3ForcePathStyleFalse(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: s3-force-path
|
||||
source:
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: source
|
||||
force_path_style: false
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`)
|
||||
|
||||
if ForcePathStyle(cfg.Pipelines[0].Source.ForcePath) {
|
||||
t.Fatal("force_path_style = true, want explicit false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsDuplicatePipelineIDs(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: duplicate
|
||||
source:
|
||||
backend: local
|
||||
path: /one
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
- id: duplicate
|
||||
source:
|
||||
backend: local
|
||||
path: /two
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`, "pipeline id duplicate is duplicated")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsDuplicateDestinationIDs(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive-one
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive-two
|
||||
`, "destination id archive is duplicated")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsMissingRequiredFields(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"pipelines": ``,
|
||||
"pipeline id": `pipelines: [{source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"source backend": `pipelines: [{id: reports, source: {path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"destinations": `pipelines: [{id: reports, source: {backend: local, path: /source}}]`,
|
||||
"destination id": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{backend: local, path: /archive}]}]`,
|
||||
"local path": `pipelines: [{id: reports, source: {backend: local}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"ssh host": `pipelines: [{id: reports, source: {backend: ssh, path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"s3 bucket": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com"}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"publish outputs": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive, publish: {source: false, html: false}}]}]`,
|
||||
}
|
||||
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertLoadError(t, body, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsInvalidS3Config(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"prefix traversal": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, prefix: "../reports"}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"prefix backslash": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, prefix: 'a\b'}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"partial creds": `pipelines: [{id: reports, source: {backend: s3, endpoint: "https://s3.example.com", bucket: source, credentials: {access_key_id_env: ACCESS_KEY_ID}}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
}
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertLoadError(t, body, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileDefaultsSSHConfig(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: ssh-defaults
|
||||
source:
|
||||
backend: ssh
|
||||
host: source.example.com
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: ssh
|
||||
host: destination.example.com
|
||||
path: /archive
|
||||
host_key_policy: false
|
||||
`)
|
||||
|
||||
source := cfg.Pipelines[0].Source
|
||||
if source.Port != 22 {
|
||||
t.Fatalf("source port = %d, want 22", source.Port)
|
||||
}
|
||||
if source.SSH.HostKeyPolicy != HostKeyPolicyAcceptNew {
|
||||
t.Fatalf("source host key policy = %q, want accept-new", source.SSH.HostKeyPolicy)
|
||||
}
|
||||
destination := cfg.Pipelines[0].Destinations[0]
|
||||
if destination.Port != 22 {
|
||||
t.Fatalf("destination port = %d, want 22", destination.Port)
|
||||
}
|
||||
if destination.SSH.HostKeyPolicy != HostKeyPolicyOff {
|
||||
t.Fatalf("destination host key policy = %q, want off", destination.SSH.HostKeyPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileNormalizesSSHHostKeyPolicies(t *testing.T) {
|
||||
tests := map[string]HostKeyPolicy{
|
||||
`true`: HostKeyPolicyStrict,
|
||||
`"true"`: HostKeyPolicyStrict,
|
||||
`strict`: HostKeyPolicyStrict,
|
||||
`accept-new`: HostKeyPolicyAcceptNew,
|
||||
`false`: HostKeyPolicyOff,
|
||||
`"false"`: HostKeyPolicyOff,
|
||||
`off`: HostKeyPolicyOff,
|
||||
`"STRICT"`: HostKeyPolicyStrict,
|
||||
`"ACCEPT-NEW"`: HostKeyPolicyAcceptNew,
|
||||
`"OFF"`: HostKeyPolicyOff,
|
||||
}
|
||||
for value, want := range tests {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: ssh-policy
|
||||
source:
|
||||
backend: ssh
|
||||
host: source.example.com
|
||||
path: /source
|
||||
host_key_policy: `+value+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`)
|
||||
if got := cfg.Pipelines[0].Source.SSH.HostKeyPolicy; got != want {
|
||||
t.Fatalf("host key policy = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsLegacySSHURIFieldAsUnknown(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: ssh
|
||||
uri: ssh://reports@example.com:22
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`, "field uri not found")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsUnsupportedBackend(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: ftp
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`, "backend ftp is unsupported")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsInvalidTransferAction(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
transfer:
|
||||
on_destination_older: overwrite
|
||||
`, "on_destination_older must be replace or fail")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsInvalidValidationAction(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
validation:
|
||||
on_digest_mismatch: warn
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`, "on_digest_mismatch must be fail")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsHTMLPublishWithoutTransform(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: static
|
||||
backend: local
|
||||
path: /srv/www
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
`, "markdown_to_html is required")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsUnknownFields(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
surprise: true
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`, "field surprise not found")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsUnknownSecretsFields(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
secrets:
|
||||
directory: /run/secrets/distributor
|
||||
surprise: true
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
`, "field surprise not found")
|
||||
}
|
||||
|
||||
func TestExampleConfigsLoad(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"../../examples/local-to-local.yml",
|
||||
"../../examples/local-publish.yml",
|
||||
"../../examples/local-html.yml",
|
||||
"../../examples/fan-out.yml",
|
||||
"../../examples/ssh-destination.yml",
|
||||
"../../examples/s3-destination.yml",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
if _, err := LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile(%q) error = %v", path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(t *testing.T, body string) Config {
|
||||
t.Helper()
|
||||
path := writeConfig(t, body)
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assertLoadError(t *testing.T, body, want string) {
|
||||
t.Helper()
|
||||
path := writeConfig(t, body)
|
||||
_, err := LoadFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("LoadFile() error = nil, want error")
|
||||
}
|
||||
if want != "" && !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("LoadFile() error = %q, want substring %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(strings.TrimSpace(body)+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
19
internal/config/s3.go
Normal file
19
internal/config/s3.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func NormalizeS3Prefix(prefix string) string {
|
||||
return strings.Trim(prefix, "/")
|
||||
}
|
||||
|
||||
func ValidateS3Prefix(prefix string) error {
|
||||
return storage.ValidatePrefix(prefix)
|
||||
}
|
||||
|
||||
func ForcePathStyle(value *bool) bool {
|
||||
return value == nil || *value
|
||||
}
|
||||
154
internal/config/secrets.go
Normal file
154
internal/config/secrets.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type EnvLookup func(string) (string, bool)
|
||||
|
||||
type Environment struct {
|
||||
values map[string]string
|
||||
lookup EnvLookup
|
||||
}
|
||||
|
||||
type SecretConflict struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type SecretLoadResult struct {
|
||||
Environment Environment
|
||||
Conflicts []SecretConflict
|
||||
}
|
||||
|
||||
type ResolvedCredentials struct {
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
}
|
||||
|
||||
func ProcessEnvironment() Environment {
|
||||
return NewEnvironment(nil, os.LookupEnv)
|
||||
}
|
||||
|
||||
func NewEnvironment(values map[string]string, lookup EnvLookup) Environment {
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
if lookup == nil {
|
||||
lookup = os.LookupEnv
|
||||
}
|
||||
return Environment{values: copied, lookup: lookup}
|
||||
}
|
||||
|
||||
func (e Environment) Lookup(name string) (string, bool) {
|
||||
if e.lookup != nil {
|
||||
if value, ok := e.lookup(name); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
value, ok := e.values[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (e Environment) ResolveCredentials(creds Credentials) (ResolvedCredentials, error) {
|
||||
var resolved ResolvedCredentials
|
||||
var err error
|
||||
if creds.AccessKeyIDEnv != "" {
|
||||
resolved.AccessKeyID, err = e.required(creds.AccessKeyIDEnv)
|
||||
if err != nil {
|
||||
return ResolvedCredentials{}, err
|
||||
}
|
||||
}
|
||||
if creds.SecretAccessKeyEnv != "" {
|
||||
resolved.SecretAccessKey, err = e.required(creds.SecretAccessKeyEnv)
|
||||
if err != nil {
|
||||
return ResolvedCredentials{}, err
|
||||
}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (e Environment) required(name string) (string, error) {
|
||||
value, ok := e.Lookup(name)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("credential environment variable %s is not set", name)
|
||||
}
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("credential environment variable %s is empty", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func LoadSecretEnvironment(directory string, lookup EnvLookup) (SecretLoadResult, error) {
|
||||
if directory == "" {
|
||||
return SecretLoadResult{Environment: NewEnvironment(nil, lookup)}, nil
|
||||
}
|
||||
values, err := loadSecretValues(directory)
|
||||
if err != nil {
|
||||
return SecretLoadResult{}, err
|
||||
}
|
||||
var conflicts []SecretConflict
|
||||
for name, value := range values {
|
||||
if processValue, ok := lookupValue(lookup, name); ok && processValue != value {
|
||||
conflicts = append(conflicts, SecretConflict{Name: name})
|
||||
}
|
||||
}
|
||||
sort.Slice(conflicts, func(i, j int) bool {
|
||||
return conflicts[i].Name < conflicts[j].Name
|
||||
})
|
||||
return SecretLoadResult{
|
||||
Environment: NewEnvironment(values, lookup),
|
||||
Conflicts: conflicts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadSecretValues(directory string) (map[string]string, error) {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load secrets directory %q: %w", directory, err)
|
||||
}
|
||||
values := make(map[string]string)
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(directory, entry.Name())
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect secret file %q: %w", entry.Name(), err)
|
||||
}
|
||||
if info.IsDir() || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if !secretNamePattern.MatchString(entry.Name()) {
|
||||
return nil, fmt.Errorf("secret filename %q is invalid", entry.Name())
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read secret file %q: %w", entry.Name(), err)
|
||||
}
|
||||
values[entry.Name()] = trimOneTrailingLineEnding(string(data))
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func trimOneTrailingLineEnding(value string) string {
|
||||
if strings.HasSuffix(value, "\r\n") {
|
||||
return strings.TrimSuffix(value, "\r\n")
|
||||
}
|
||||
if strings.HasSuffix(value, "\n") {
|
||||
return strings.TrimSuffix(value, "\n")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func lookupValue(lookup EnvLookup, name string) (string, bool) {
|
||||
if lookup == nil {
|
||||
lookup = os.LookupEnv
|
||||
}
|
||||
return lookup(name)
|
||||
}
|
||||
248
internal/config/secrets_test.go
Normal file
248
internal/config/secrets_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadSecretEnvironmentLoadsValidFiles(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeSecret(t, directory, "API_KEY", "value\n")
|
||||
writeSecret(t, directory, "CRLF", "value\r\n")
|
||||
writeSecret(t, directory, "MULTILINE", "value\n\n")
|
||||
writeSecret(t, directory, "SPACES", " value \n")
|
||||
writeSecret(t, directory, "CARRIAGE", "value\r")
|
||||
|
||||
result, err := LoadSecretEnvironment(directory, emptyLookup)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
assertEnvValue(t, result.Environment, "API_KEY", "value")
|
||||
assertEnvValue(t, result.Environment, "CRLF", "value")
|
||||
assertEnvValue(t, result.Environment, "MULTILINE", "value\n")
|
||||
assertEnvValue(t, result.Environment, "SPACES", " value ")
|
||||
assertEnvValue(t, result.Environment, "CARRIAGE", "value\r")
|
||||
}
|
||||
|
||||
func TestLoadSecretEnvironmentRejectsInvalidFilenames(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
secretValue := "do-not-print"
|
||||
writeSecret(t, directory, "1INVALID", secretValue)
|
||||
|
||||
_, err := LoadSecretEnvironment(directory, emptyLookup)
|
||||
if err == nil {
|
||||
t.Fatal("LoadSecretEnvironment() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "secret filename") {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %q, want filename error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), secretValue) {
|
||||
t.Fatalf("LoadSecretEnvironment() error exposed secret value: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretEnvironmentIgnoresDirectoriesAndFollowsSymlinks(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(directory, "IGNORED_DIR"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir ignored dir: %v", err)
|
||||
}
|
||||
targetFile := filepath.Join(t.TempDir(), "target")
|
||||
if err := os.WriteFile(targetFile, []byte("linked\n"), 0o600); err != nil {
|
||||
t.Fatalf("write target file: %v", err)
|
||||
}
|
||||
if err := os.Symlink(targetFile, filepath.Join(directory, "LINKED_SECRET")); err != nil {
|
||||
t.Fatalf("symlink file: %v", err)
|
||||
}
|
||||
targetDir := t.TempDir()
|
||||
if err := os.Symlink(targetDir, filepath.Join(directory, "LINKED_DIR")); err != nil {
|
||||
t.Fatalf("symlink dir: %v", err)
|
||||
}
|
||||
|
||||
result, err := LoadSecretEnvironment(directory, emptyLookup)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
assertEnvValue(t, result.Environment, "LINKED_SECRET", "linked")
|
||||
if _, ok := result.Environment.Lookup("IGNORED_DIR"); ok {
|
||||
t.Fatal("directory appeared in environment")
|
||||
}
|
||||
if _, ok := result.Environment.Lookup("LINKED_DIR"); ok {
|
||||
t.Fatal("directory symlink appeared in environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSecretEnvironmentMissingDirectoryFails(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
_, err := LoadSecretEnvironment(missing, emptyLookup)
|
||||
if err == nil {
|
||||
t.Fatal("LoadSecretEnvironment() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "load secrets directory") || !strings.Contains(err.Error(), missing) {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %q, want directory context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentPrefersProcessValuesAndReportsDifferingConflicts(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
secretValue := "secret-value"
|
||||
processValue := "process-value"
|
||||
writeSecret(t, directory, "TOKEN", secretValue)
|
||||
|
||||
result, err := LoadSecretEnvironment(directory, mapLookup(map[string]string{"TOKEN": processValue}))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
assertEnvValue(t, result.Environment, "TOKEN", processValue)
|
||||
if got, want := len(result.Conflicts), 1; got != want {
|
||||
t.Fatalf("conflict count = %d, want %d", got, want)
|
||||
}
|
||||
if result.Conflicts[0].Name != "TOKEN" {
|
||||
t.Fatalf("conflict name = %q, want TOKEN", result.Conflicts[0].Name)
|
||||
}
|
||||
if strings.Contains(result.Conflicts[0].Name, secretValue) || strings.Contains(result.Conflicts[0].Name, processValue) {
|
||||
t.Fatalf("conflict exposed secret values: %#v", result.Conflicts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentDoesNotWarnWhenProcessValueMatchesSecret(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeSecret(t, directory, "TOKEN", "same-value")
|
||||
|
||||
result, err := LoadSecretEnvironment(directory, mapLookup(map[string]string{"TOKEN": "same-value"}))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
if len(result.Conflicts) != 0 {
|
||||
t.Fatalf("conflicts = %#v, want none", result.Conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentDoesNotMutateProcessEnvironment(t *testing.T) {
|
||||
name := "DISTRIBUTOR_TEST_SECRET_ONLY"
|
||||
t.Setenv(name, "")
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatalf("unset env: %v", err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
writeSecret(t, directory, name, "secret")
|
||||
|
||||
if _, err := LoadSecretEnvironment(directory, nil); err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
t.Fatalf("%s was added to process environment", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCredentialsUsesSecretsAwareEnvironment(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeSecret(t, directory, "ACCESS_KEY_ID", "secret-access")
|
||||
writeSecret(t, directory, "SECRET_ACCESS_KEY", "secret-secret")
|
||||
result, err := LoadSecretEnvironment(directory, emptyLookup)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
|
||||
creds, err := result.Environment.ResolveCredentials(Credentials{
|
||||
AccessKeyIDEnv: "ACCESS_KEY_ID",
|
||||
SecretAccessKeyEnv: "SECRET_ACCESS_KEY",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveCredentials() error = %v", err)
|
||||
}
|
||||
if creds.AccessKeyID != "secret-access" || creds.SecretAccessKey != "secret-secret" {
|
||||
t.Fatalf("resolved credentials = %#v", creds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCredentialsPrefersProcessEnvironment(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeSecret(t, directory, "ACCESS_KEY_ID", "secret-access")
|
||||
result, err := LoadSecretEnvironment(directory, mapLookup(map[string]string{"ACCESS_KEY_ID": "process-access"}))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
|
||||
creds, err := result.Environment.ResolveCredentials(Credentials{AccessKeyIDEnv: "ACCESS_KEY_ID"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveCredentials() error = %v", err)
|
||||
}
|
||||
if creds.AccessKeyID != "process-access" {
|
||||
t.Fatalf("access key = %q, want process-access", creds.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCredentialsDoesNotFeedProcessEnvironment(t *testing.T) {
|
||||
name := "DISTRIBUTOR_TEST_SDK_CHAIN_VALUE"
|
||||
t.Setenv(name, "")
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatalf("unset env: %v", err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
writeSecret(t, directory, name, "secret")
|
||||
|
||||
result, err := LoadSecretEnvironment(directory, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecretEnvironment() error = %v", err)
|
||||
}
|
||||
assertEnvValue(t, result.Environment, name, "secret")
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
t.Fatalf("%s is visible to process environment", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCredentialsMissingReferenceFailsWithoutSecretValue(t *testing.T) {
|
||||
env := NewEnvironment(map[string]string{"PRESENT": "do-not-print"}, emptyLookup)
|
||||
_, err := env.ResolveCredentials(Credentials{AccessKeyIDEnv: "MISSING"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveCredentials() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MISSING") {
|
||||
t.Fatalf("ResolveCredentials() error = %q, want missing variable name", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "do-not-print") {
|
||||
t.Fatalf("ResolveCredentials() error exposed secret value: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCredentialsRejectsEmptyReferencedValue(t *testing.T) {
|
||||
env := NewEnvironment(map[string]string{"EMPTY": ""}, emptyLookup)
|
||||
_, err := env.ResolveCredentials(Credentials{AccessKeyIDEnv: "EMPTY"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveCredentials() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "EMPTY") || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("ResolveCredentials() error = %q, want empty variable context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSecret(t *testing.T, directory, name, value string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte(value), 0o600); err != nil {
|
||||
t.Fatalf("write secret %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnvValue(t *testing.T, env Environment, name, want string) {
|
||||
t.Helper()
|
||||
got, ok := env.Lookup(name)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) ok = false", name)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("Lookup(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func emptyLookup(string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func mapLookup(values map[string]string) EnvLookup {
|
||||
return func(name string) (string, bool) {
|
||||
value, ok := values[name]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
64
internal/config/ssh.go
Normal file
64
internal/config/ssh.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type HostKeyPolicy string
|
||||
|
||||
const (
|
||||
HostKeyPolicyStrict HostKeyPolicy = "strict"
|
||||
HostKeyPolicyAcceptNew HostKeyPolicy = "accept-new"
|
||||
HostKeyPolicyOff HostKeyPolicy = "off"
|
||||
)
|
||||
|
||||
func (p *HostKeyPolicy) UnmarshalYAML(value *yaml.Node) error {
|
||||
switch value.Kind {
|
||||
case yaml.ScalarNode:
|
||||
default:
|
||||
return fmt.Errorf("host_key_policy must be a boolean or string")
|
||||
}
|
||||
|
||||
switch value.Tag {
|
||||
case "!!bool":
|
||||
var enabled bool
|
||||
if err := value.Decode(&enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
*p = HostKeyPolicyStrict
|
||||
} else {
|
||||
*p = HostKeyPolicyOff
|
||||
}
|
||||
return nil
|
||||
case "!!str":
|
||||
var raw string
|
||||
if err := value.Decode(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, ok := NormalizeHostKeyPolicy(raw)
|
||||
if !ok {
|
||||
return fmt.Errorf("host_key_policy must be strict, true, accept-new, off, or false")
|
||||
}
|
||||
*p = normalized
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("host_key_policy must be a boolean or string")
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeHostKeyPolicy(value string) (HostKeyPolicy, bool) {
|
||||
switch strings.ToLower(value) {
|
||||
case "", string(HostKeyPolicyAcceptNew):
|
||||
return HostKeyPolicyAcceptNew, true
|
||||
case string(HostKeyPolicyStrict), "true":
|
||||
return HostKeyPolicyStrict, true
|
||||
case string(HostKeyPolicyOff), "false":
|
||||
return HostKeyPolicyOff, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
181
internal/config/validate.go
Normal file
181
internal/config/validate.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
|
||||
|
||||
type ValidationErrors []string
|
||||
|
||||
func (e ValidationErrors) Error() string {
|
||||
if len(e) == 1 {
|
||||
return e[0]
|
||||
}
|
||||
return strings.Join(e, "; ")
|
||||
}
|
||||
|
||||
func Validate(cfg Config) error {
|
||||
var errs ValidationErrors
|
||||
|
||||
if len(cfg.Pipelines) == 0 {
|
||||
errs = append(errs, "pipelines is required")
|
||||
}
|
||||
|
||||
pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines))
|
||||
for pipelineIndex, pipeline := range cfg.Pipelines {
|
||||
pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex)
|
||||
if pipeline.ID == "" {
|
||||
errs = append(errs, pipelineContext+".id is required")
|
||||
} else if !idPattern.MatchString(pipeline.ID) {
|
||||
errs = append(errs, pipelineContext+".id must be a slug-like identifier")
|
||||
} else if _, exists := pipelineIDs[pipeline.ID]; exists {
|
||||
errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated")
|
||||
} else {
|
||||
pipelineIDs[pipeline.ID] = struct{}{}
|
||||
}
|
||||
|
||||
errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source)
|
||||
errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation)
|
||||
if len(pipeline.Destinations) == 0 {
|
||||
errs = append(errs, pipelineContext+".destinations is required")
|
||||
}
|
||||
|
||||
destinationIDs := make(map[string]struct{}, len(pipeline.Destinations))
|
||||
for destinationIndex, destination := range pipeline.Destinations {
|
||||
destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex)
|
||||
if destination.ID == "" {
|
||||
errs = append(errs, destinationContext+".id is required")
|
||||
} else if !idPattern.MatchString(destination.ID) {
|
||||
errs = append(errs, destinationContext+".id must be a slug-like identifier")
|
||||
} else if _, exists := destinationIDs[destination.ID]; exists {
|
||||
errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID)
|
||||
} else {
|
||||
destinationIDs[destination.ID] = struct{}{}
|
||||
}
|
||||
|
||||
errs = validateDestinationBackend(errs, destinationContext, destination)
|
||||
errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform)
|
||||
errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSourceBackend(errs ValidationErrors, context string, backend Backend) ValidationErrors {
|
||||
return validateBackend(errs, context, backend.Backend, backend.Host, backend.Port, backend.Path, backend.Endpoint, backend.Bucket, backend.Prefix, backend.SSH.HostKeyPolicy, backend.Creds)
|
||||
}
|
||||
|
||||
func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors {
|
||||
return validateBackend(errs, context, destination.Backend, destination.Host, destination.Port, destination.Path, destination.Endpoint, destination.Bucket, destination.Prefix, destination.SSH.HostKeyPolicy, destination.Creds)
|
||||
}
|
||||
|
||||
func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, endpoint, bucket, prefix string, hostKeyPolicy HostKeyPolicy, creds Credentials) ValidationErrors {
|
||||
switch backend {
|
||||
case "":
|
||||
errs = append(errs, context+".backend is required")
|
||||
case BackendLocal:
|
||||
if path == "" {
|
||||
errs = append(errs, context+".path is required for local backend")
|
||||
}
|
||||
case BackendSSH:
|
||||
if host == "" {
|
||||
errs = append(errs, context+".host is required for ssh backend")
|
||||
}
|
||||
if path == "" {
|
||||
errs = append(errs, context+".path is required for ssh backend")
|
||||
}
|
||||
if port < 0 || port > 65535 {
|
||||
errs = append(errs, context+".port must be between 1 and 65535")
|
||||
}
|
||||
if port == 0 {
|
||||
errs = append(errs, context+".port is required for ssh backend after defaults are applied")
|
||||
}
|
||||
if hostKeyPolicy != "" {
|
||||
if _, ok := NormalizeHostKeyPolicy(string(hostKeyPolicy)); !ok {
|
||||
errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false")
|
||||
}
|
||||
}
|
||||
case BackendS3:
|
||||
if endpoint == "" {
|
||||
errs = append(errs, context+".endpoint is required for s3 backend")
|
||||
}
|
||||
if bucket == "" {
|
||||
errs = append(errs, context+".bucket is required for s3 backend")
|
||||
}
|
||||
if err := ValidateS3Prefix(prefix); err != nil {
|
||||
errs = append(errs, context+".prefix must be a clean relative slash-separated path")
|
||||
}
|
||||
if (creds.AccessKeyIDEnv == "") != (creds.SecretAccessKeyEnv == "") {
|
||||
errs = append(errs, context+".credentials.access_key_id_env and credentials.secret_access_key_env must be configured together")
|
||||
}
|
||||
default:
|
||||
errs = append(errs, context+".backend "+backend+" is unsupported")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateValidationPolicy(errs ValidationErrors, context string, policy ValidationPolicy) ValidationErrors {
|
||||
if policy.OnDigestMismatch != ValidationActionFail {
|
||||
errs = append(errs, context+".on_digest_mismatch must be "+ValidationActionFail)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validatePublishTransformPolicy(errs ValidationErrors, context string, policy *PublishPolicy, transform Transform) ValidationErrors {
|
||||
if policy == nil {
|
||||
errs = append(errs, context+".publish is required")
|
||||
return errs
|
||||
}
|
||||
if err := ValidatePublishTransformPolicy(*policy, transform); err != nil {
|
||||
errs = append(errs, context+"."+err.Error())
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform) error {
|
||||
if !publish.Source && !publish.HTML {
|
||||
return fmt.Errorf("publish must enable source or html")
|
||||
}
|
||||
if publish.HTML && transform.MarkdownToHTML == nil {
|
||||
return fmt.Errorf("transform.markdown_to_html is required when publish.html is true")
|
||||
}
|
||||
if transform.MarkdownToHTML == nil {
|
||||
return nil
|
||||
}
|
||||
if publish.HTML && !transform.MarkdownToHTML.Enabled {
|
||||
return fmt.Errorf("transform.markdown_to_html.enabled must be true when publish.html is true")
|
||||
}
|
||||
if publish.HTML && transform.MarkdownToHTML.Mode != TransformModeSidecar {
|
||||
return fmt.Errorf("transform.markdown_to_html.mode must be %s", TransformModeSidecar)
|
||||
}
|
||||
if transform.MarkdownToHTML.Enabled && transform.MarkdownToHTML.Mode != TransformModeSidecar {
|
||||
return fmt.Errorf("transform.markdown_to_html.mode must be %s", TransformModeSidecar)
|
||||
}
|
||||
if !transform.MarkdownToHTML.Enabled && transform.MarkdownToHTML.Mode != "" && transform.MarkdownToHTML.Mode != TransformModeSidecar {
|
||||
return fmt.Errorf("transform.markdown_to_html.mode must be %s", TransformModeSidecar)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors {
|
||||
if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail {
|
||||
errs = append(errs, context+".on_destination_same must be skip or fail")
|
||||
}
|
||||
if policy.OnDestinationOlder != TransferActionReplace && policy.OnDestinationOlder != TransferActionFail {
|
||||
errs = append(errs, context+".on_destination_older must be replace or fail")
|
||||
}
|
||||
if policy.OnDestinationNewer != TransferActionSkip && policy.OnDestinationNewer != TransferActionFail && policy.OnDestinationNewer != TransferActionReplace {
|
||||
errs = append(errs, context+".on_destination_newer must be skip, replace, or fail")
|
||||
}
|
||||
if policy.OnConflict != TransferActionFail && policy.OnConflict != TransferActionReplace {
|
||||
errs = append(errs, context+".on_conflict must be fail or replace")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
164
internal/config/validate_test.go
Normal file
164
internal/config/validate_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePublishTransformPolicy(t *testing.T) {
|
||||
tests := publishTransformPolicyCases()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePublishTransformPolicy(tt.publish, tt.transform)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("ValidatePublishTransformPolicy() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("ValidatePublishTransformPolicy() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChecksPublishTransformPolicy(t *testing.T) {
|
||||
tests := publishTransformPolicyCases()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Config{Pipelines: []Pipeline{{
|
||||
ID: "reports",
|
||||
Source: Backend{
|
||||
Backend: BackendLocal,
|
||||
Path: "/source",
|
||||
},
|
||||
Destinations: []Destination{{
|
||||
ID: "archive",
|
||||
Backend: BackendLocal,
|
||||
Path: "/destination",
|
||||
Publish: &tt.publish,
|
||||
Transform: tt.transform,
|
||||
}},
|
||||
}}}
|
||||
ApplyDefaults(&cfg)
|
||||
err := Validate(cfg)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsForceReplacementTransferActions(t *testing.T) {
|
||||
cfg := Config{Pipelines: []Pipeline{{
|
||||
ID: "reports",
|
||||
Source: Backend{
|
||||
Backend: BackendLocal,
|
||||
Path: "/source",
|
||||
},
|
||||
Destinations: []Destination{{
|
||||
ID: "archive",
|
||||
Backend: BackendLocal,
|
||||
Path: "/destination",
|
||||
Transfer: TransferPolicy{
|
||||
OnDestinationNewer: TransferActionReplace,
|
||||
OnConflict: TransferActionReplace,
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
ApplyDefaults(&cfg)
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type publishTransformPolicyCase struct {
|
||||
name string
|
||||
publish PublishPolicy
|
||||
transform Transform
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
return []publishTransformPolicyCase{
|
||||
{
|
||||
name: "source only allowed",
|
||||
publish: PublishPolicy{Source: true},
|
||||
},
|
||||
{
|
||||
name: "html only sidecar allowed",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: TransformModeSidecar,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "source and html sidecar allowed",
|
||||
publish: PublishPolicy{Source: true, HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: TransformModeSidecar,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "no outputs rejected",
|
||||
publish: PublishPolicy{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "html without transform rejected",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "html with disabled transform rejected",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: TransformModeSidecar,
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "html with wrong mode rejected",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: "inline",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "enabled markdown wrong mode rejected",
|
||||
publish: PublishPolicy{Source: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: "inline",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled markdown empty mode allowed",
|
||||
publish: PublishPolicy{Source: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: false,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "disabled markdown sidecar mode allowed",
|
||||
publish: PublishPolicy{Source: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: TransformModeSidecar,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "disabled markdown wrong mode rejected",
|
||||
publish: PublishPolicy{Source: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: "inline",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
7
internal/logging/logging.go
Normal file
7
internal/logging/logging.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package logging
|
||||
|
||||
import "io"
|
||||
|
||||
func Configure(io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
9
internal/notify/noop.go
Normal file
9
internal/notify/noop.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package notify
|
||||
|
||||
import "context"
|
||||
|
||||
type Noop struct{}
|
||||
|
||||
func (Noop) Notify(ctx context.Context, event Event) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
25
internal/notify/notify.go
Normal file
25
internal/notify/notify.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package notify
|
||||
|
||||
import "context"
|
||||
|
||||
type Event struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
BundleID string
|
||||
BundlePath string
|
||||
Action string
|
||||
Outputs []Output
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
Path string
|
||||
Kind string
|
||||
SourcePath string
|
||||
Transform string
|
||||
SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, event Event) error
|
||||
}
|
||||
109
internal/publish/execute.go
Normal file
109
internal/publish/execute.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
switch plan.Action {
|
||||
case ActionSkipSame, ActionSkipDestinationNewer:
|
||||
return nil
|
||||
case ActionPublishNew, ActionReplaceOlder, ActionForceReplace:
|
||||
default:
|
||||
return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason)
|
||||
}
|
||||
|
||||
if plan.Action == ActionReplaceOlder {
|
||||
if plan.ExistingState == nil {
|
||||
return fmt.Errorf("replace requires existing destination state")
|
||||
}
|
||||
if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, existingManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if plan.Action == ActionForceReplace {
|
||||
if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
writtenOutputs := make([]Output, 0, len(plan.Outputs))
|
||||
cleanup := func() {
|
||||
_ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, managedOutputPaths(writtenOutputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
|
||||
}
|
||||
for _, output := range plan.Outputs {
|
||||
destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
data := output.Data
|
||||
if output.Kind == state.OutputKindSource {
|
||||
sourcePath, err := storage.Join(req.SourceBundle.RootRelativePath, output.SourcePath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
data, err = req.SourceBackend.ReadFile(ctx, sourcePath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := req.DestinationBackend.WriteFile(ctx, destinationPath, data, storage.WriteOptions{Overwrite: false, PreferAtomic: true}); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
writtenOutputs = append(writtenOutputs, output)
|
||||
}
|
||||
|
||||
destinationState := state.DistributorState{
|
||||
SchemaVersion: state.SchemaVersion,
|
||||
DistributorVersion: req.DistributorVersion,
|
||||
PipelineID: req.PipelineID,
|
||||
DestinationID: req.DestinationID,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Source: state.SourceState{Manifest: req.SourceBundle.Manifest},
|
||||
Outputs: stateOutputs(plan.Outputs),
|
||||
}
|
||||
if err := state.Validate(destinationState); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(destinationState, "", " ")
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
statePath, err := storage.StatePath(req.DestinationBundlePath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if _, err := req.DestinationBackend.WriteFile(ctx, statePath, data, storage.WriteOptions{Overwrite: false, PreferAtomic: true}); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingManagedOutputPaths(destinationState state.DistributorState) []string {
|
||||
paths := make([]string, 0, len(destinationState.Outputs))
|
||||
for _, output := range destinationState.Outputs {
|
||||
paths = append(paths, output.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
64
internal/publish/execute_test.go
Normal file
64
internal/publish/execute_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestExecuteCleansUpAfterWriteFailure(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
destinationBackend := &failingBackend{Backend: fake.New(), failPath: "summary.txt"}
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{})
|
||||
req := Request{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
SourceBundle: sourceBundle,
|
||||
SourceBackend: sourceBackend,
|
||||
DestinationBackend: destinationBackend,
|
||||
DestinationBundlePath: "",
|
||||
Publish: config.PublishPolicy{Source: true},
|
||||
Transfer: config.TransferPolicy{OnDestinationSame: config.TransferActionSkip, OnDestinationOlder: config.TransferActionReplace, OnDestinationNewer: config.TransferActionSkip, OnConflict: config.TransferActionFail},
|
||||
DistributorVersion: "test",
|
||||
}
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
err = Execute(context.Background(), req, plan)
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want error")
|
||||
}
|
||||
found, err := destinationBackend.HasAny(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny() error = %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("destination has content after failed execution")
|
||||
}
|
||||
}
|
||||
|
||||
type failingBackend struct {
|
||||
*fake.Backend
|
||||
failPath string
|
||||
}
|
||||
|
||||
func (b *failingBackend) WriteFile(ctx context.Context, path string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if path == b.failPath {
|
||||
return storage.Entry{}, fmt.Errorf("injected write failure")
|
||||
}
|
||||
return b.Backend.WriteFile(ctx, path, data, opts)
|
||||
}
|
||||
|
||||
func (b *failingBackend) WriteFrom(ctx context.Context, path string, r io.Reader, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if path == b.failPath {
|
||||
return storage.Entry{}, fmt.Errorf("injected write failure")
|
||||
}
|
||||
return b.Backend.WriteFrom(ctx, path, r, opts)
|
||||
}
|
||||
241
internal/publish/force_test.go
Normal file
241
internal/publish/force_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, backend *fake.Backend, source bundle.Manifest)
|
||||
transfer config.TransferPolicy
|
||||
wantReason string
|
||||
forceAction bool
|
||||
}{
|
||||
{
|
||||
name: "unmanaged content",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
writeFakeFile(t, backend, "bundle/old.txt", "old")
|
||||
},
|
||||
transfer: defaultTransfer(),
|
||||
wantReason: "fail_unmanaged",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "different source id",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
conflict := source
|
||||
conflict.ID = "other.source"
|
||||
writeFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "same created digest conflict",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}})
|
||||
writeFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "pipeline mismatch",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
writeFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "destination mismatch",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
writeFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "newer destination",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
newer := source
|
||||
newer.Created = newer.Created.AddDate(0, 0, 1)
|
||||
writeFakeDestinationState(t, backend, "bundle", newer, testutil.DestinationStateOptions{})
|
||||
},
|
||||
transfer: newerReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
|
||||
destinationBackend := fake.New()
|
||||
tt.prepare(t, destinationBackend, sourceBundle.Manifest)
|
||||
|
||||
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, tt.transfer)
|
||||
_, err := Build(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantReason) {
|
||||
t.Fatalf("Build() error = %v, want %q", err, tt.wantReason)
|
||||
}
|
||||
|
||||
req.Force = true
|
||||
plan, err := Build(context.Background(), req)
|
||||
if tt.forceAction {
|
||||
if err != nil {
|
||||
t.Fatalf("Build() with force error = %v", err)
|
||||
}
|
||||
if plan.Action != ActionForceReplace || !plan.Force {
|
||||
t.Fatalf("forced plan action = %s force=%t", plan.Action, plan.Force)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequiresConflictPolicyForStateConflicts(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
|
||||
destinationBackend := fake.New()
|
||||
conflict := sourceBundle.Manifest
|
||||
conflict.ID = "other.source"
|
||||
writeFakeDestinationState(t, destinationBackend, "bundle", conflict, testutil.DestinationStateOptions{})
|
||||
|
||||
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, defaultTransfer())
|
||||
req.Force = true
|
||||
_, err := Build(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "destination source id differs") {
|
||||
t.Fatalf("Build() error = %v, want conservative conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteForcedReplacementDeletesOnlyBundlePath(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
|
||||
destinationBackend := fake.New()
|
||||
writeFakeFile(t, destinationBackend, "bundle/old.txt", "old")
|
||||
writeFakeFile(t, destinationBackend, "bundle/nested/old.txt", "old")
|
||||
writeFakeFile(t, destinationBackend, "bundle-sibling/keep.txt", "keep")
|
||||
writeFakeFile(t, destinationBackend, "outside.txt", "outside")
|
||||
|
||||
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, defaultTransfer())
|
||||
req.Force = true
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if plan.Action != ActionForceReplace {
|
||||
t.Fatalf("plan action = %s, want force_replace", plan.Action)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
assertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nSunny.\n")
|
||||
assertFakeMissing(t, destinationBackend, "bundle/old.txt")
|
||||
assertFakeMissing(t, destinationBackend, "bundle/nested/old.txt")
|
||||
assertFakeFile(t, destinationBackend, "bundle-sibling/keep.txt", "keep")
|
||||
assertFakeFile(t, destinationBackend, "outside.txt", "outside")
|
||||
}
|
||||
|
||||
func forceRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle bundle.Bundle, transfer config.TransferPolicy) Request {
|
||||
return Request{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
SourceBundle: sourceBundle,
|
||||
SourceBackend: sourceBackend,
|
||||
DestinationBackend: destinationBackend,
|
||||
DestinationBundlePath: sourceBundle.RootRelativePath,
|
||||
Publish: config.PublishPolicy{Source: true},
|
||||
Transfer: transfer,
|
||||
DistributorVersion: "test",
|
||||
}
|
||||
}
|
||||
|
||||
func defaultTransfer() config.TransferPolicy {
|
||||
return config.TransferPolicy{
|
||||
OnDestinationSame: config.TransferActionSkip,
|
||||
OnDestinationOlder: config.TransferActionReplace,
|
||||
OnDestinationNewer: config.TransferActionSkip,
|
||||
OnConflict: config.TransferActionFail,
|
||||
}
|
||||
}
|
||||
|
||||
func conflictReplaceTransfer() config.TransferPolicy {
|
||||
transfer := defaultTransfer()
|
||||
transfer.OnConflict = config.TransferActionReplace
|
||||
return transfer
|
||||
}
|
||||
|
||||
func newerReplaceTransfer() config.TransferPolicy {
|
||||
transfer := defaultTransfer()
|
||||
transfer.OnDestinationNewer = config.TransferActionReplace
|
||||
return transfer
|
||||
}
|
||||
|
||||
func writeFakeDestinationState(t *testing.T, backend *fake.Backend, relative string, manifest bundle.Manifest, opts testutil.DestinationStateOptions) {
|
||||
t.Helper()
|
||||
destinationState := testutil.DestinationState(manifest, opts)
|
||||
data, err := json.MarshalIndent(destinationState, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal destination state: %v", err)
|
||||
}
|
||||
statePath, err := storage.StatePath(relative)
|
||||
if err != nil {
|
||||
t.Fatalf("state path: %v", err)
|
||||
}
|
||||
writeFakeFile(t, backend, statePath, string(append(data, '\n')))
|
||||
for _, output := range destinationState.Outputs {
|
||||
path, err := storage.Join(relative, output.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("join output path: %v", err)
|
||||
}
|
||||
writeFakeFile(t, backend, path, "old")
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeFile(t *testing.T, backend *fake.Backend, path, data string) {
|
||||
t.Helper()
|
||||
if _, err := backend.WriteFile(context.Background(), path, []byte(data), storage.WriteOptions{}); err != nil {
|
||||
t.Fatalf("write fake file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFakeFile(t *testing.T, backend *fake.Backend, path, want string) {
|
||||
t.Helper()
|
||||
data, err := backend.ReadFile(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fake file %s: %v", path, err)
|
||||
}
|
||||
if got := string(data); got != want {
|
||||
t.Fatalf("fake file %s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFakeMissing(t *testing.T, backend *fake.Backend, path string) {
|
||||
t.Helper()
|
||||
if _, err := backend.Stat(context.Background(), path); !storage.IsNotFound(err) {
|
||||
t.Fatalf("fake file %s stat error = %v, want not found", path, err)
|
||||
}
|
||||
}
|
||||
117
internal/publish/output.go
Normal file
117
internal/publish/output.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform"
|
||||
)
|
||||
|
||||
func PlanOutputs(ctx context.Context, req Request) ([]Output, error) {
|
||||
var outputs []Output
|
||||
if req.Publish.Source {
|
||||
sourceOutputs, err := PlanSourceOutputs(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputs = append(outputs, sourceOutputs...)
|
||||
}
|
||||
if req.Publish.HTML {
|
||||
transformer, err := resolveTransformer(req.Transformers, transform.MarkdownToHTML)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
generatedOutputs, err := transformer.Generate(ctx, transform.Request{
|
||||
SourceBundle: req.SourceBundle,
|
||||
SourceBackend: req.SourceBackend,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(generatedOutputs) == 0 {
|
||||
return nil, fmt.Errorf("publish html requested but no markdown source files were found")
|
||||
}
|
||||
for _, generated := range generatedOutputs {
|
||||
outputs = append(outputs, Output{
|
||||
SourcePath: generated.SourcePath,
|
||||
DestinationPath: generated.Path,
|
||||
Kind: state.OutputKindGenerated,
|
||||
Transform: generated.Transform,
|
||||
Data: generated.Data,
|
||||
SHA256: generated.SHA256,
|
||||
Size: generated.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := rejectOutputCollisions(outputs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outputs, nil
|
||||
}
|
||||
|
||||
func resolveTransformer(resolver TransformerResolver, name string) (transform.Transformer, error) {
|
||||
if resolver == nil {
|
||||
return nil, fmt.Errorf("transformer resolver is required for %s", name)
|
||||
}
|
||||
transformer, ok := resolver.Get(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("transformer %s is not registered", name)
|
||||
}
|
||||
return transformer, nil
|
||||
}
|
||||
|
||||
func PlanSourceOutputs(req Request) ([]Output, error) {
|
||||
outputs := make([]Output, 0, len(req.SourceBundle.Manifest.Files))
|
||||
for _, file := range req.SourceBundle.Manifest.Files {
|
||||
if err := storage.ValidatePath(file.Path); err != nil {
|
||||
return nil, fmt.Errorf("destination output path %q: %w", file.Path, err)
|
||||
}
|
||||
outputs = append(outputs, Output{
|
||||
SourcePath: file.Path,
|
||||
DestinationPath: file.Path,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: file.SHA256,
|
||||
Size: file.Size,
|
||||
})
|
||||
}
|
||||
return outputs, nil
|
||||
}
|
||||
|
||||
func rejectOutputCollisions(outputs []Output) error {
|
||||
seen := make(map[string]struct{}, len(outputs))
|
||||
for _, output := range outputs {
|
||||
if err := storage.ValidatePath(output.DestinationPath); err != nil {
|
||||
return fmt.Errorf("destination output path %q: %w", output.DestinationPath, err)
|
||||
}
|
||||
if _, exists := seen[output.DestinationPath]; exists {
|
||||
return fmt.Errorf("destination output path collision: %s", output.DestinationPath)
|
||||
}
|
||||
seen[output.DestinationPath] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stateOutputs(outputs []Output) []state.OutputFile {
|
||||
files := make([]state.OutputFile, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
files = append(files, state.OutputFile{
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func managedOutputPaths(outputs []Output) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.DestinationPath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
255
internal/publish/output_test.go
Normal file
255
internal/publish/output_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform"
|
||||
)
|
||||
|
||||
func TestPlanOutputsRejectsCollision(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
|
||||
Files: []testutil.SourceFile{
|
||||
{Path: "report.md", Data: "# Report\n"},
|
||||
{Path: "report.html", Data: "<p>source html</p>\n"},
|
||||
},
|
||||
})
|
||||
_, err := PlanOutputs(context.Background(), Request{
|
||||
SourceBackend: sourceBackend,
|
||||
SourceBundle: sourceBundle,
|
||||
Publish: config.PublishPolicy{Source: true, HTML: true},
|
||||
Transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{Enabled: true, Mode: config.TransformModeSidecar}},
|
||||
Transformers: testResolver{transform.MarkdownToHTML: testTransformer{outputs: []transform.Output{{
|
||||
Path: "report.html",
|
||||
SourcePath: "report.md",
|
||||
Transform: transform.MarkdownToHTML,
|
||||
Data: []byte("<p>Report</p>\n"),
|
||||
SHA256: bundle.FileDigest([]byte("<p>Report</p>\n")),
|
||||
Size: int64(len("<p>Report</p>\n")),
|
||||
}}}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PlanSourceOutputs() error = nil, want collision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanOutputsRejectsHTMLWithoutMarkdown(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
|
||||
Files: []testutil.SourceFile{{Path: "summary.txt", Data: "Summary\n"}},
|
||||
})
|
||||
_, err := PlanOutputs(context.Background(), Request{
|
||||
SourceBackend: sourceBackend,
|
||||
SourceBundle: sourceBundle,
|
||||
Publish: config.PublishPolicy{HTML: true},
|
||||
Transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{Enabled: true, Mode: config.TransformModeSidecar}},
|
||||
Transformers: testResolver{
|
||||
transform.MarkdownToHTML: testTransformer{},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PlanOutputs() error = nil, want no markdown failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanOutputsRejectsHTMLWithoutTransformerResolver(t *testing.T) {
|
||||
_, err := PlanOutputs(context.Background(), Request{
|
||||
Publish: config.PublishPolicy{HTML: true},
|
||||
Transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{Enabled: true, Mode: config.TransformModeSidecar}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PlanOutputs() error = nil, want resolver failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanOutputsRejectsMissingMarkdownTransformer(t *testing.T) {
|
||||
_, err := PlanOutputs(context.Background(), Request{
|
||||
Publish: config.PublishPolicy{HTML: true},
|
||||
Transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{Enabled: true, Mode: config.TransformModeSidecar}},
|
||||
Transformers: testResolver{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("PlanOutputs() error = nil, want missing transformer failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanOutputsUsesRegisteredTransformer(t *testing.T) {
|
||||
data := []byte("<p>Generated</p>\n")
|
||||
outputs, err := PlanOutputs(context.Background(), Request{
|
||||
Publish: config.PublishPolicy{HTML: true},
|
||||
Transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{Enabled: true, Mode: config.TransformModeSidecar}},
|
||||
Transformers: testResolver{transform.MarkdownToHTML: testTransformer{outputs: []transform.Output{{
|
||||
Path: "report.html",
|
||||
SourcePath: "report.md",
|
||||
Transform: transform.MarkdownToHTML,
|
||||
Data: data,
|
||||
SHA256: bundle.FileDigest(data),
|
||||
Size: int64(len(data)),
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanOutputs() error = %v", err)
|
||||
}
|
||||
if got, want := len(outputs), 1; got != want {
|
||||
t.Fatalf("output count = %d, want %d", got, want)
|
||||
}
|
||||
if outputs[0].DestinationPath != "report.html" || string(outputs[0].Data) != string(data) {
|
||||
t.Fatalf("output = %#v", outputs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRejectsHTMLWithoutTransform(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
destinationBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
|
||||
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\n"}},
|
||||
})
|
||||
_, err := Build(context.Background(), Request{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
SourceBackend: sourceBackend,
|
||||
DestinationBackend: destinationBackend,
|
||||
DestinationBundlePath: "",
|
||||
SourceBundle: sourceBundle,
|
||||
Publish: config.PublishPolicy{HTML: true},
|
||||
Transfer: config.TransferPolicy{
|
||||
OnDestinationSame: config.TransferActionSkip,
|
||||
OnDestinationOlder: config.TransferActionReplace,
|
||||
OnDestinationNewer: config.TransferActionSkip,
|
||||
OnConflict: config.TransferActionFail,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Build() error = nil, want missing transform error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestChecksPublishTransformPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
publish config.PublishPolicy
|
||||
transform config.Transform
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "source only allowed",
|
||||
publish: config.PublishPolicy{Source: true},
|
||||
},
|
||||
{
|
||||
name: "html only sidecar allowed",
|
||||
publish: config.PublishPolicy{HTML: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: config.TransformModeSidecar,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "source and html sidecar allowed",
|
||||
publish: config.PublishPolicy{Source: true, HTML: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: config.TransformModeSidecar,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "no outputs rejected",
|
||||
publish: config.PublishPolicy{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "html without transform rejected",
|
||||
publish: config.PublishPolicy{HTML: true},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "html with disabled transform rejected",
|
||||
publish: config.PublishPolicy{HTML: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: config.TransformModeSidecar,
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "html with wrong mode rejected",
|
||||
publish: config.PublishPolicy{HTML: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: "inline",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "enabled markdown wrong mode rejected",
|
||||
publish: config.PublishPolicy{Source: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: "inline",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled markdown empty mode allowed",
|
||||
publish: config.PublishPolicy{Source: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: false,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "disabled markdown sidecar mode allowed",
|
||||
publish: config.PublishPolicy{Source: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: config.TransformModeSidecar,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "disabled markdown wrong mode rejected",
|
||||
publish: config.PublishPolicy{Source: true},
|
||||
transform: config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: "inline",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRequest(Request{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
SourceBackend: fake.New(),
|
||||
DestinationBackend: fake.New(),
|
||||
Publish: tt.publish,
|
||||
Transform: tt.transform,
|
||||
})
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("validateRequest() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("validateRequest() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testResolver map[string]transform.Transformer
|
||||
|
||||
func (r testResolver) Get(name string) (transform.Transformer, bool) {
|
||||
transformer, ok := r[name]
|
||||
return transformer, ok
|
||||
}
|
||||
|
||||
type testTransformer struct {
|
||||
outputs []transform.Output
|
||||
err error
|
||||
}
|
||||
|
||||
func (t testTransformer) Generate(context.Context, transform.Request) ([]transform.Output, error) {
|
||||
return t.outputs, t.err
|
||||
}
|
||||
162
internal/publish/plan.go
Normal file
162
internal/publish/plan.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionPublishNew Action = "publish_new"
|
||||
ActionReplaceOlder Action = "replace_older"
|
||||
ActionSkipSame Action = "skip_same"
|
||||
ActionSkipDestinationNewer Action = "skip_destination_newer"
|
||||
ActionFailConflict Action = "fail_conflict"
|
||||
ActionFailUnmanaged Action = "fail_unmanaged"
|
||||
ActionForceReplace Action = "force_replace"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
SourceBundle bundle.Bundle
|
||||
SourceBackend storage.Backend
|
||||
DestinationBackend storage.Backend
|
||||
DestinationBundlePath string
|
||||
Publish config.PublishPolicy
|
||||
Transform config.Transform
|
||||
Transformers TransformerResolver
|
||||
Transfer config.TransferPolicy
|
||||
DistributorVersion string
|
||||
Force bool
|
||||
}
|
||||
|
||||
type TransformerResolver interface {
|
||||
Get(name string) (transform.Transformer, bool)
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
BundleID string
|
||||
BundlePath string
|
||||
DestinationBundlePath string
|
||||
Action Action
|
||||
Reason string
|
||||
Force bool
|
||||
Outputs []Output
|
||||
ExistingState *state.DistributorState
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
SourcePath string
|
||||
DestinationPath string
|
||||
Kind string
|
||||
Transform string
|
||||
Data []byte
|
||||
SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func Build(ctx context.Context, req Request) (Plan, error) {
|
||||
if err := validateRequest(req); err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
outputs, err := PlanOutputs(ctx, req)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
status, err := inspectDestination(ctx, req.DestinationBackend, req.DestinationBundlePath)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
comparison := state.Compare(req.SourceBundle.Manifest, req.PipelineID, req.DestinationID, status)
|
||||
action, reason := actionForComparison(comparison, req.Transfer, req.Force)
|
||||
plan := Plan{
|
||||
PipelineID: req.PipelineID,
|
||||
DestinationID: req.DestinationID,
|
||||
BundleID: req.SourceBundle.Manifest.ID,
|
||||
BundlePath: req.SourceBundle.RootRelativePath,
|
||||
DestinationBundlePath: req.DestinationBundlePath,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
Force: action == ActionForceReplace,
|
||||
Outputs: outputs,
|
||||
ExistingState: status.State,
|
||||
}
|
||||
if action == ActionFailConflict || action == ActionFailUnmanaged {
|
||||
return plan, fmt.Errorf("%s: %s", action, reason)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func validateRequest(req Request) error {
|
||||
if req.PipelineID == "" {
|
||||
return fmt.Errorf("pipeline id is required")
|
||||
}
|
||||
if req.DestinationID == "" {
|
||||
return fmt.Errorf("destination id is required")
|
||||
}
|
||||
if req.SourceBackend == nil {
|
||||
return fmt.Errorf("source backend is required")
|
||||
}
|
||||
if req.DestinationBackend == nil {
|
||||
return fmt.Errorf("destination backend is required")
|
||||
}
|
||||
if err := config.ValidatePublishTransformPolicy(req.Publish, req.Transform); err != nil {
|
||||
return fmt.Errorf("publish/transform policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func actionForComparison(comparison state.Comparison, transfer config.TransferPolicy, force bool) (Action, string) {
|
||||
switch comparison.Outcome {
|
||||
case state.OutcomeDestinationAbsent:
|
||||
return ActionPublishNew, comparison.Reason
|
||||
case state.OutcomeDestinationUnmanaged:
|
||||
if force {
|
||||
return ActionForceReplace, "forced replacement of unmanaged destination content"
|
||||
}
|
||||
return ActionFailUnmanaged, comparison.Reason
|
||||
case state.OutcomeInvalidState:
|
||||
return ActionFailConflict, comparison.Reason
|
||||
case state.OutcomeIdentityMismatch, state.OutcomeSameCreatedConflict, state.OutcomeDifferentSourceConflict:
|
||||
if transfer.OnConflict == config.TransferActionReplace {
|
||||
if force {
|
||||
return ActionForceReplace, "forced replacement of conflicting destination state: " + comparison.Reason
|
||||
}
|
||||
return ActionFailConflict, "destination conflict replacement requires --force"
|
||||
}
|
||||
return ActionFailConflict, comparison.Reason
|
||||
case state.OutcomeSameSource:
|
||||
if transfer.OnDestinationSame == config.TransferActionFail {
|
||||
return ActionFailConflict, "destination matches source and transfer policy requires failure"
|
||||
}
|
||||
return ActionSkipSame, comparison.Reason
|
||||
case state.OutcomeDestinationOlder:
|
||||
if transfer.OnDestinationOlder == config.TransferActionFail {
|
||||
return ActionFailConflict, "destination is older and transfer policy requires failure"
|
||||
}
|
||||
return ActionReplaceOlder, comparison.Reason
|
||||
case state.OutcomeDestinationNewer:
|
||||
if transfer.OnDestinationNewer == config.TransferActionReplace {
|
||||
if force {
|
||||
return ActionForceReplace, "forced replacement of newer destination state"
|
||||
}
|
||||
return ActionFailConflict, "destination is newer and replacement requires --force"
|
||||
}
|
||||
if transfer.OnDestinationNewer == config.TransferActionFail {
|
||||
return ActionFailConflict, "destination is newer and transfer policy requires failure"
|
||||
}
|
||||
return ActionSkipDestinationNewer, comparison.Reason
|
||||
default:
|
||||
return ActionFailConflict, "unsupported comparison outcome"
|
||||
}
|
||||
}
|
||||
31
internal/publish/reconcile.go
Normal file
31
internal/publish/reconcile.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func inspectDestination(ctx context.Context, backend storage.Backend, bundlePath string) (state.DestinationStatus, error) {
|
||||
statePath, err := storage.StatePath(bundlePath)
|
||||
if err != nil {
|
||||
return state.DestinationStatus{}, err
|
||||
}
|
||||
data, err := backend.ReadFile(ctx, statePath)
|
||||
if err == nil {
|
||||
destinationState, parseErr := state.Parse(data)
|
||||
if parseErr != nil {
|
||||
return state.DestinationStatus{StateErr: parseErr}, nil
|
||||
}
|
||||
return state.DestinationStatus{State: &destinationState, HasContents: true}, nil
|
||||
}
|
||||
if !storage.IsNotFound(err) {
|
||||
return state.DestinationStatus{}, err
|
||||
}
|
||||
hasContents, err := backend.HasAny(ctx, bundlePath)
|
||||
if err != nil {
|
||||
return state.DestinationStatus{}, err
|
||||
}
|
||||
return state.DestinationStatus{HasContents: hasContents}, nil
|
||||
}
|
||||
19
internal/publish/safety.go
Normal file
19
internal/publish/safety.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
func ensureDestinationEmpty(ctx context.Context, backend storage.Backend, bundlePath string) error {
|
||||
hasAny, err := backend.HasAny(ctx, bundlePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasAny {
|
||||
return fmt.Errorf("destination bundle path %q is not empty after managed cleanup", storage.DisplayPath(bundlePath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
89
internal/state/compare.go
Normal file
89
internal/state/compare.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
OutcomeDestinationAbsent Outcome = "destination_absent"
|
||||
OutcomeDestinationUnmanaged Outcome = "destination_unmanaged"
|
||||
OutcomeInvalidState Outcome = "invalid_destination_state"
|
||||
OutcomeIdentityMismatch Outcome = "destination_identity_mismatch"
|
||||
OutcomeSameSource Outcome = "same_source_manifest"
|
||||
OutcomeDestinationOlder Outcome = "destination_older"
|
||||
OutcomeDestinationNewer Outcome = "destination_newer"
|
||||
OutcomeSameCreatedConflict Outcome = "same_created_digest_conflict"
|
||||
OutcomeDifferentSourceConflict Outcome = "different_source_conflict"
|
||||
)
|
||||
|
||||
type DestinationStatus struct {
|
||||
State *DistributorState
|
||||
StateErr error
|
||||
HasContents bool
|
||||
}
|
||||
|
||||
type Comparison struct {
|
||||
Outcome Outcome
|
||||
Reason string
|
||||
}
|
||||
|
||||
func Compare(source bundle.Manifest, pipelineID, destinationID string, status DestinationStatus) Comparison {
|
||||
if status.StateErr != nil {
|
||||
return Comparison{Outcome: OutcomeInvalidState, Reason: status.StateErr.Error()}
|
||||
}
|
||||
if status.State == nil {
|
||||
if status.HasContents {
|
||||
return Comparison{Outcome: OutcomeDestinationUnmanaged, Reason: "destination has content but no distributor state"}
|
||||
}
|
||||
return Comparison{Outcome: OutcomeDestinationAbsent, Reason: "destination state is absent"}
|
||||
}
|
||||
|
||||
destinationState := *status.State
|
||||
if err := Validate(destinationState); err != nil {
|
||||
return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error()}
|
||||
}
|
||||
if destinationState.PipelineID != pipelineID {
|
||||
return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("pipeline id %q does not match %q", destinationState.PipelineID, pipelineID)}
|
||||
}
|
||||
if destinationState.DestinationID != destinationID {
|
||||
return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("destination id %q does not match %q", destinationState.DestinationID, destinationID)}
|
||||
}
|
||||
|
||||
destinationManifest := destinationState.Source.Manifest
|
||||
if manifestsEqual(source, destinationManifest) {
|
||||
return Comparison{Outcome: OutcomeSameSource, Reason: "destination source manifest matches source"}
|
||||
}
|
||||
if destinationManifest.ID != source.ID {
|
||||
return Comparison{Outcome: OutcomeDifferentSourceConflict, Reason: "destination source id differs from source"}
|
||||
}
|
||||
if destinationManifest.Created.Before(source.Created) {
|
||||
return Comparison{Outcome: OutcomeDestinationOlder, Reason: "destination source is older than source"}
|
||||
}
|
||||
if destinationManifest.Created.After(source.Created) {
|
||||
return Comparison{Outcome: OutcomeDestinationNewer, Reason: "destination source is newer than source"}
|
||||
}
|
||||
if destinationManifest.Digest != source.Digest {
|
||||
return Comparison{Outcome: OutcomeSameCreatedConflict, Reason: "destination source has same id and created time but different digest"}
|
||||
}
|
||||
return Comparison{Outcome: OutcomeInvalidState, Reason: "destination source differs from source without a supported comparison outcome"}
|
||||
}
|
||||
|
||||
func manifestsEqual(a, b bundle.Manifest) bool {
|
||||
if a.SchemaVersion != b.SchemaVersion ||
|
||||
a.ID != b.ID ||
|
||||
a.Digest != b.Digest ||
|
||||
!a.Created.Equal(b.Created) ||
|
||||
len(a.Files) != len(b.Files) {
|
||||
return false
|
||||
}
|
||||
for index := range a.Files {
|
||||
if a.Files[index] != b.Files[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
121
internal/state/compare_test.go
Normal file
121
internal/state/compare_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
)
|
||||
|
||||
func TestCompareOutcomes(t *testing.T) {
|
||||
source := validManifest(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
status DestinationStatus
|
||||
want Outcome
|
||||
}{
|
||||
{
|
||||
name: "destination absent",
|
||||
status: DestinationStatus{},
|
||||
want: OutcomeDestinationAbsent,
|
||||
},
|
||||
{
|
||||
name: "destination unmanaged",
|
||||
status: DestinationStatus{HasContents: true},
|
||||
want: OutcomeDestinationUnmanaged,
|
||||
},
|
||||
{
|
||||
name: "invalid destination state",
|
||||
status: DestinationStatus{StateErr: errors.New("invalid json")},
|
||||
want: OutcomeInvalidState,
|
||||
},
|
||||
{
|
||||
name: "pipeline mismatch",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.PipelineID = "other" })},
|
||||
want: OutcomeIdentityMismatch,
|
||||
},
|
||||
{
|
||||
name: "destination mismatch",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.DestinationID = "other" })},
|
||||
want: OutcomeIdentityMismatch,
|
||||
},
|
||||
{
|
||||
name: "same source manifest",
|
||||
status: DestinationStatus{State: withState(t, source, nil)},
|
||||
want: OutcomeSameSource,
|
||||
},
|
||||
{
|
||||
name: "destination older",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
|
||||
s.Source.Manifest.Created = source.Created.Add(-time.Hour)
|
||||
})},
|
||||
want: OutcomeDestinationOlder,
|
||||
},
|
||||
{
|
||||
name: "destination newer",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
|
||||
s.Source.Manifest.Created = source.Created.Add(time.Hour)
|
||||
})},
|
||||
want: OutcomeDestinationNewer,
|
||||
},
|
||||
{
|
||||
name: "same created digest conflict",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
|
||||
s.Source.Manifest.Files[0].SHA256 = "sha256:3333333333333333333333333333333333333333333333333333333333333333"
|
||||
s.Source.Manifest.Digest = bundle.BundleDigest(s.Source.Manifest.Files)
|
||||
})},
|
||||
want: OutcomeSameCreatedConflict,
|
||||
},
|
||||
{
|
||||
name: "different source id",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
|
||||
s.Source.Manifest.ID = "other.source"
|
||||
})},
|
||||
want: OutcomeDifferentSourceConflict,
|
||||
},
|
||||
{
|
||||
name: "invalid state object",
|
||||
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
|
||||
s.Outputs[0].Kind = "other"
|
||||
})},
|
||||
want: OutcomeInvalidState,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Compare(source, "reports", "archive", tt.status)
|
||||
if got.Outcome != tt.want {
|
||||
t.Fatalf("Compare() outcome = %s reason=%q, want %s", got.Outcome, got.Reason, tt.want)
|
||||
}
|
||||
if got.Reason == "" {
|
||||
t.Fatal("Compare() reason is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func withState(t *testing.T, source bundle.Manifest, mutate func(*DistributorState)) *DistributorState {
|
||||
t.Helper()
|
||||
stateManifest := source
|
||||
stateManifest.Files = append([]bundle.ManifestFile(nil), source.Files...)
|
||||
state := DistributorState{
|
||||
SchemaVersion: SchemaVersion,
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
PublishedAt: time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC),
|
||||
Source: SourceState{Manifest: stateManifest},
|
||||
Outputs: []OutputFile{{
|
||||
Path: "report.md",
|
||||
Kind: OutputKindSource,
|
||||
SourcePath: "report.md",
|
||||
SHA256: source.Files[0].SHA256,
|
||||
Size: source.Files[0].Size,
|
||||
}},
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&state)
|
||||
}
|
||||
return &state
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user