48 Commits

Author SHA1 Message Date
ee6a351960 Clean up upload API documentation roadmap
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-06-08 04:48:06 +00:00
29f01da37b Document pipeline-scoped upload behavior 2026-06-08 04:46:22 +00:00
bd5892d1f2 Add upload pipeline integration coverage 2026-06-08 04:41:05 +00:00
ce43a6044a Route upload client by pipeline 2026-06-08 04:37:33 +00:00
1c5d7198e3 Scope upload idempotency by token 2026-06-08 04:34:29 +00:00
9d4694c6d8 Route uploads by pipeline path 2026-06-08 04:32:20 +00:00
033b2e5015 Add upload token config validation 2026-06-08 04:27:49 +00:00
4fa7d1ebb5 Add roadmap and an implementation plan for HTTP API upgrades 2026-06-07 23:19:26 -05:00
f98e528c90 Update documentation to clarity bundle_id and idempotency_key usage and distinctions 2026-06-07 20:45:29 -05:00
25fbfc4677 Update documentation relating to the public packages and http_upload API 2026-06-07 13:33:27 -05:00
c12ec64066 Consolidate future roadmap documentation
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-06-04 14:42:16 +00:00
f9142fded4 Document producer upload client behavior 2026-06-04 14:15:27 +00:00
d637949db4 Add producer upload client package 2026-06-04 14:10:51 +00:00
a15722571f Add HTTP upload idempotency support 2026-06-04 14:03:26 +00:00
1a402e6cfa Added a roadmap and an implentation plan for a new public http upload helper package 2026-06-04 08:55:43 -05:00
6085344a0b Validate documentation roadmap completion
Some checks failed
ci/woodpecker/tag/release Pipeline failed
2026-06-04 12:23:12 +00:00
c23e8e66ba Clean up completed documentation roadmaps 2026-06-04 12:21:26 +00:00
bed425ab78 Normalize internal component documentation 2026-06-04 12:18:30 +00:00
a81f686fae Add integration contract documentation 2026-06-04 12:15:21 +00:00
ecc5254e6b Refresh operations and troubleshooting documentation 2026-06-04 12:10:50 +00:00
18bba116f2 Refresh configuration reference documentation 2026-06-04 12:07:41 +00:00
b19128b77e Refresh README and CLI documentation 2026-06-04 12:04:42 +00:00
f3fb51ce7b Plan documentation roadmap cleanup 2026-06-04 12:00:35 +00:00
9000e12d47 Centralize CLI flag set setup 2026-06-04 00:45:12 +00:00
982e7e9863 Remove unused pipeline run coordinator 2026-06-04 00:43:19 +00:00
2ac2bbdf79 Align bundle path validation coverage 2026-06-04 00:40:17 +00:00
5a3fd2b8ac Share command output projections 2026-06-04 00:35:19 +00:00
7cf8f74c3e Normalize backend config validation 2026-06-04 00:31:33 +00:00
9143a00bff Extract destination run processing 2026-06-04 00:28:23 +00:00
fc16443370 Centralize runtime config setup 2026-06-04 00:22:09 +00:00
0d346dcdf5 Stage uploads before accepting HTTP runs 2026-06-04 00:14:32 +00:00
1340418a2b Create a cleanup roadmap to address the items identified in the audit 2026-06-03 19:07:47 -05:00
6d409fb4bd Audit code quality and deduplication opportunities 2026-06-03 18:46:10 -05:00
dc1f1f11f9 Close out HTTP upload documentation 2026-06-03 15:30:02 +00:00
0f1ef9e622 Add HTTP upload end-to-end coverage 2026-06-03 15:26:49 +00:00
6beef58dbf Add HTTP upload server and serve command 2026-06-03 15:22:52 +00:00
f0c10210eb Add async upload coordination 2026-06-03 15:15:22 +00:00
f9436a7423 Add local source pipeline execution 2026-06-03 15:10:07 +00:00
65dd22f974 Add upload archive staging 2026-06-03 15:05:56 +00:00
35c5237dfc Add HTTP upload configuration support 2026-06-03 15:01:01 +00:00
28eb5e07a0 Add implementation roadmap for HTTP API 2026-06-03 09:53:51 -05:00
22ce15c707 Refresh app and HTTP boundary documentation 2026-06-03 11:57:37 +00:00
00677148e2 Stabilize app run internals 2026-06-03 11:54:20 +00:00
87fcd0277b Record future HTTP boundary contract 2026-06-03 11:51:48 +00:00
7b2caf4c01 Add pipeline run coordinator 2026-06-03 11:50:19 +00:00
761a2f0bc2 Add single-pipeline run entrypoint 2026-06-03 11:46:44 +00:00
f236a8086a Implement structured run reporting 2026-06-03 11:43:50 +00:00
44df38e555 Add a stabilization roadmap to support a future HTTP API 2026-06-02 14:51:52 -05:00
83 changed files with 10065 additions and 1678 deletions

View File

@@ -1,15 +1,21 @@
# distributor # distributor
`distributor` validates manifested report bundles and publishes selected source or generated artifacts to configured destinations. `distributor` validates manifested report bundles, plans destination updates, and publishes selected source files or generated HTML outputs to configured destinations.
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 or `index.html`. It is a local-first Go CLI for report distribution. A pipeline reads one source bundle tree, validates `manifest.json`, fans out to one or more local, SSH/SFTP, or S3-compatible destinations, records destination state in `.distributor.json`, and can also accept authenticated tar or tar.gz uploads through the HTTP upload server.
Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to build, write, parse, and validate complete local source bundles with the same manifest contract used by `distributor`. Run the maintained local example:
Run the local example pipeline:
```sh ```sh
go run ./cmd/distributor run --config examples/local-publish.yml 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. Future and deferred work lives under `docs/roadmap/`. Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/upload` and `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to submit compatible bundles to `distributor serve`. See [Upstream producer integration](docs/consumers/api.md).
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md)
- [Consumer API guide](docs/consumers/api.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Integration contracts](docs/integrations/source-bundle.md)
- [Development architecture](docs/policy/architecture.md)

View File

@@ -1,170 +1,201 @@
# Distributor CLI # Distributor CLI
## Shortest useful command Audience: operators, integrators, and developers who run `distributor` from a shell or automation.
This document is the canonical command and flag reference. Configuration schema details live in [Configuration](config.md), operational recovery guidance lives in [Operations](operations.md), failure diagnosis lives in [Troubleshooting](troubleshooting.md), and external contracts live under [Integrations](integrations/source-bundle.md).
## Shortest Useful Command
Run the maintained local publishing example from the repository root:
```sh ```sh
go run ./cmd/distributor run --config examples/local-publish.yml 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`. The example reads `examples/source-bundle/manifest.json`, publishes the configured files into `workspace/published/source-bundle`, and writes destination state metadata beside the published output.
## Command overview ## Command Overview
```sh ```text
distributor [--help] distributor [--help]
distributor help
distributor version [--format text|json] distributor version [--format text|json]
distributor run [--config <path>] [--dry-run] [--force] [--format text|json] distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
distributor serve [--config <path>]
distributor validate [--format text|json] <path> distributor validate [--format text|json] <path>
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json] distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
distributor inspect [--format text|json] <path> distributor inspect [--format text|json] <path>
distributor inspect --config <path> --pipeline <id> [--bundle <path>] [--format text|json] distributor inspect --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
distributor manifest
distributor manifest create <bundle-path> --id <bundle-id> [options] distributor manifest create <bundle-path> --id <bundle-id> [options]
distributor manifest create --id <bundle-id> [options] <bundle-path>
``` ```
- `version`: prints the application name and version. Development builds print `distributor dev`. - `version` prints the application name and version.
- `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. - `run` executes configured pipelines against their destinations.
- `validate`: validates a local source bundle directory, a local source bundle tree, or one configured pipeline source. - `serve` starts the authenticated HTTP upload API defined by the configuration file.
- `inspect`: validates source bundles and prints normalized bundle metadata for a local path or one configured pipeline source. - `validate` checks a local bundle path or a configured source bundle.
- `manifest create`: creates `manifest.json` for a local source bundle directory. - `inspect` reports manifest and file metadata for a local bundle path or a configured source bundle.
- `manifest create` writes a `manifest.json` file for an existing bundle directory.
`validate` and `inspect` have two mutually exclusive modes: a local path shortcut, or configured source mode with `--config <path> --pipeline <id>`. Configured source mode opens only the selected pipeline source and supports configured `local`, `ssh`, and `s3` sources. It does not open destinations. `run` executes configured sources and destinations. ## Flag Reference
## Flag reference ### Help
Root command: `distributor`, `distributor --help`, `distributor -h`, `distributor help`, and `distributor manifest` print command help. Unknown commands and invalid argument combinations print usage guidance and exit non-zero.
- `--help`, `-h`, or `help`: print root help. ### Common Output Format
All subcommands: `--format text|json` is supported by `version`, `run`, `validate`, `inspect`, and `manifest create`.
- `--help`, `-h`: print command-specific help. - `text` is the default human-readable output.
- `json` emits one JSON document for successful command execution.
- Invalid formats are rejected before command execution.
Output-producing subcommands: ### `version`
- `--format text|json`: output format. `text` is the default. Help and usage output are always text. ```sh
distributor version [--format text|json]
```
`run` flags: `version` accepts no positional arguments. Text output prints the application name and version; JSON output includes `application` and `version` fields.
- `--config <path>`: config file to load. If omitted, `run` uses `/usr/local/etc/distributor/config.yml`. ### `run`
- `--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.
`validate` and `inspect` configured source flags: ```sh
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
```
- `--config <path>`: config file to load for source validation or inspection. Required in configured source mode. - `--config <path>` loads the pipeline configuration. If omitted, the application uses `/usr/local/etc/distributor/config.yml`.
- `--pipeline <id>`: pipeline source to validate or inspect. Required in configured source mode. - `--dry-run` validates inputs and reports destination actions without applying changes.
- `--bundle <path>`: source-root-relative bundle directory to validate or inspect instead of discovering every bundle under the source root. - `--force` permits a run when destination state indicates a conservative safety check would otherwise block it.
- `--format text|json` selects human-readable or machine-readable output.
`manifest create` flags: `run` accepts no positional arguments.
- `--id <bundle-id>`: source bundle id. Required. ### `serve`
- `--file <path>`: bundle-relative file to include. Repeatable. If omitted, files are scanned recursively.
- `--created <time>`: RFC3339 source created timestamp. If omitted, the current UTC time is used.
- `--overwrite`: replace an existing `manifest.json`.
`run` does not accept positional arguments. `validate` and `inspect` accept at most one path in local mode. Local paths cannot be combined with `--config`, `--pipeline`, or `--bundle`. ```sh
distributor serve [--config <path>]
```
## Common workflows - `--config <path>` loads HTTP, source, destination, and pipeline configuration. If omitted, the application uses `/usr/local/etc/distributor/config.yml`.
Validate a source bundle: `serve` accepts no positional arguments and runs until interrupted or until the server exits with an error.
### `validate`
```sh
distributor validate [--format text|json] <path>
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
```
`validate` has two source modes:
- Local path mode validates the bundle at `<path>`.
- Configured source mode resolves the source from `--config <path>` and `--pipeline <id>`.
Configured source flags:
- `--config <path>` loads the configuration file.
- `--pipeline <id>` selects the configured pipeline source to validate.
- `--bundle <path>` overrides the configured source bundle path for the selected pipeline.
- `--format text|json` selects output format.
A local positional path cannot be combined with `--config`, `--pipeline`, or `--bundle`. When any configured source flag is used, both `--config` and `--pipeline` are required.
### `inspect`
```sh
distributor inspect [--format text|json] <path>
distributor inspect --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
```
`inspect` uses the same source mode rules as `validate`, then reports bundle metadata instead of only validation status. Local path mode requires exactly one bundle path. Configured source mode requires both `--config` and `--pipeline`; `--bundle` may override the selected pipeline source path.
### `manifest create`
```sh
distributor manifest create <bundle-path> --id <bundle-id> [options]
distributor manifest create --id <bundle-id> [options] <bundle-path>
```
Flags may appear before or after the bundle path. Both `--flag value` and `--flag=value` forms are accepted.
- `--id <bundle-id>` sets the manifest bundle identifier and is required.
- `--created <timestamp>` sets the manifest creation timestamp. If omitted, the current UTC time is used.
- `--file <relative-path>` includes one file in the manifest. The flag may be repeated.
- `--overwrite` allows replacing an existing `manifest.json` file.
- `--format text|json` selects output format.
If no `--file` flags are provided, `manifest create` scans the bundle directory recursively. The command requires exactly one bundle path, refuses unsafe manifest paths, and writes `manifest.json` at the bundle root.
## Common Workflows
### Validate Or Inspect A Local Bundle
```sh ```sh
go run ./cmd/distributor validate examples/source-bundle go run ./cmd/distributor validate examples/source-bundle
go run ./cmd/distributor inspect --format json examples/source-bundle
``` ```
Inspect a source bundle: ### Validate Or Inspect A Configured Source
```sh
go run ./cmd/distributor inspect examples/source-bundle
```
Validate a configured source without opening destinations:
```sh ```sh
go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle
go run ./cmd/distributor inspect --config examples/local-publish.yml --pipeline example-source-bundle --format json
``` ```
Inspect one configured source bundle: Use `--bundle <path>` with configured source mode when automation needs to validate or inspect an alternate bundle path through the selected pipeline configuration.
### Create A Manifest
```sh ```sh
go run ./cmd/distributor inspect \ go run ./cmd/distributor manifest create examples/source-bundle --id example-source-bundle --overwrite
--config <config-path> \ go run ./cmd/distributor manifest create --id example-source-bundle --overwrite examples/source-bundle
--pipeline <pipeline-id> \
--bundle daily/2026-06-01
``` ```
Create a manifest for a local producer bundle: Use repeated `--file` flags when the manifest should include an explicit file list instead of the recursive directory scan:
```sh ```sh
go run ./cmd/distributor manifest create <bundle-path> --id <bundle-id> go run ./cmd/distributor manifest create examples/source-bundle \
``` --id example-source-bundle \
Create a manifest with explicit file order:
```sh
go run ./cmd/distributor manifest create <bundle-path> \
--id <bundle-id> \
--created 2026-06-01T11:00:00Z \
--file report.md \ --file report.md \
--file summary.txt --file summary.txt \
--overwrite
``` ```
Preview local publication without writing: ### Preview Or Publish A Pipeline
```sh ```sh
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run 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 go run ./cmd/distributor run --config examples/local-publish.yml
``` ```
Publish the local HTML example: Use `--format json` when automation needs structured run results. Use `--force` only when the operator has reviewed the destination state conflict and intentionally wants to continue.
```sh ### Run HTML And Fan-Out Examples
go run ./cmd/distributor run --config examples/local-html.yml
```
Preview local fan-out publication:
```sh ```sh
go run ./cmd/distributor run --config examples/local-html.yml --dry-run
go run ./cmd/distributor run --config examples/local-index.yml --dry-run
go run ./cmd/distributor run --config examples/fan-out.yml --dry-run go run ./cmd/distributor run --config examples/fan-out.yml --dry-run
```
Preview local archive-plus-latest publication:
```sh
go run ./cmd/distributor run --config examples/archive-and-latest.yml --dry-run go run ./cmd/distributor run --config examples/archive-and-latest.yml --dry-run
``` ```
Preview a forced replacement before publishing: These examples exercise implemented output rendering and destination planning behavior. They still use the same `run` flags and output contract described here.
### Start The HTTP Upload Server
```sh ```sh
go run ./cmd/distributor run --config <config-path> --dry-run --force go run ./cmd/distributor serve --config examples/http-upload-local.yml
``` ```
## Output The server exposes health, status, and authenticated upload endpoints according to the loaded configuration. Use [Operations](operations.md) for server operation and recovery guidance.
Text output is the default and is intended for humans. ## Output And Exit Behavior
`run` text output 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 source bundle path, destination id, destination backend, action, outputs, and reason. Fixed path destinations also print `path_mapping=fixed target=.` to show that the selected bundle targets the destination backend root. Actions include: Text output is optimized for direct operator use. JSON output is optimized for automation and uses a command-specific result object with a shared envelope similar to:
- `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.
Dry-run output for fixed path destinations prints a warning with the candidate count and selected source bundle. If a fixed path dry run plans a destructive replacement, it prints an additional warning that the destination root would be replaced.
The final status line includes counters for `publish_new`, `replace_older`, `force_replace`, skipped destinations, failures, whether the run was a dry run, and fixed path destinations.
JSON output writes exactly one JSON document to stdout:
```json ```json
{ {
@@ -176,20 +207,16 @@ JSON output writes exactly one JSON document to stdout:
} }
``` ```
Warnings are objects in the top-level `warnings` array and are not printed again as text. Fatal setup errors, such as a missing config file or invalid arguments, write no JSON document and return a non-zero exit code with a text error on stderr. - Successful JSON commands emit one JSON document on stdout.
- Usage errors and fatal setup errors exit non-zero and do not emit a JSON result document.
- `run --format json` emits a JSON result for partial destination failures, sets `ok` to `false`, includes result details and errors, and exits non-zero.
- Warnings are included in JSON output and are printed in text output when relevant.
`run --format json` returns partial results when destination failures occur after planning or execution begins. In that case stdout contains `ok: false`, a `result` with pipeline summaries, destination actions, final counters, and a top-level `errors` array; the process still exits non-zero. ## Diagnostics And Recovery
Command-specific JSON results: - Use `validate` before `run` when checking a bundle supplied by another process.
- Use `inspect --format json` when automation needs manifest metadata, normalized file details, or checksum information.
- `version`: application name and version. - Use `run --dry-run` before publishing to review destination actions.
- `validate`: bundle count and discovered bundle identifiers. Configured source results also include pipeline id and source backend. - Use [Configuration](config.md) for schema and default details.
- `inspect`: bundle path, id, created timestamp, digest, file count, total size, and manifest file records. Configured source results also include pipeline id and source backend. - Use [Troubleshooting](troubleshooting.md) for common errors and corrective action.
- `manifest create`: manifest path, bundle root, id, created timestamp, digest, file count, and file records. - Use [Operations](operations.md) for HTTP upload operation, state files, and recovery workflows.
- `run`: dry-run status, pipeline summaries, destination action records, destination bundle paths, path mapping markers, optional primary URLs, output records with optional URLs, final counters, warnings, and partial failure records.
## Diagnostics
Use `manifest create` when a local producer has written bundle files but not `manifest.json`. Use `validate` before publication when a producer has written a new bundle; use configured source mode when the bundle is already on an SSH or S3 source. 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).

View File

@@ -1,16 +1,25 @@
# Configuration Reference # Configuration Reference
## Config File Location Audience: administrators, operators, and advanced users who write YAML configuration for `distributor`.
`distributor run --config <path>` loads the YAML config at the provided path. This document is the canonical user-facing configuration reference. CLI syntax lives in [CLI](cli.md), operating procedures live in [Operations](operations.md), symptom-oriented recovery lives in [Troubleshooting](troubleshooting.md), and external contracts live under [Integrations](integrations/source-bundle.md).
If `--config` is omitted, `run` uses: ## Config File Loading
`distributor run --config <path>` and `distributor serve --config <path>` load the YAML file at `<path>`. If `--config` is omitted, both commands use:
```text ```text
/usr/local/etc/distributor/config.yml /usr/local/etc/distributor/config.yml
``` ```
Config parsing rejects unknown YAML fields. The executable backends are `local`, `ssh`, and `s3`. YAML decoding rejects unknown fields. Defaults are applied after decoding and before validation.
Runtime backend support is command-specific:
- `run`, `validate --config`, and `inspect --config` execute `local`, `ssh`, and `s3` sources.
- `run` executes `local`, `ssh`, and `s3` destinations.
- `serve` uses `http_upload` sources through the HTTP upload API and publishes to configured `local`, `ssh`, and `s3` destinations.
- `http_upload` is valid only as a source backend.
## Minimal Local Config ## Minimal Local Config
@@ -26,11 +35,21 @@ pipelines:
path: /srv/reports/archive path: /srv/reports/archive
``` ```
This publishes source files only. It uses the default validation and transfer policies. This config publishes source files only. It uses default validation, destination path mapping, publish, transfer, and HTTP server values.
## Production-Oriented Local Config ## Production-Oriented Local Config
```yaml ```yaml
server:
http:
bind: 127.0.0.1:8080
staging_root: /var/spool/distributor
max_upload_size: 20MB
queue_size: 16
max_concurrency: 1
retention: 24h
secrets:
directory: /run/secrets/distributor
pipelines: pipelines:
- id: reports - id: reports
source: source:
@@ -45,6 +64,8 @@ pipelines:
publish: publish:
source: true source: true
html: false html: false
path_mapping:
mode: preserve_relative
transfer: transfer:
on_destination_same: skip on_destination_same: skip
on_destination_older: replace on_destination_older: replace
@@ -52,9 +73,228 @@ pipelines:
on_conflict: fail on_conflict: fail
``` ```
## HTML Publication ## HTTP Upload Source Config
To publish generated sidecar HTML from Markdown files: HTTP upload sources are configured on pipelines and are served by `distributor serve`. Upload tokens are resolved from the process environment or `secrets.directory`; literal bearer tokens are not configured in YAML.
```yaml
server:
http:
bind: 127.0.0.1:8080
staging_root: /var/spool/distributor
max_upload_size: 20MB
queue_size: 16
max_concurrency: 1
retention: 24h
upload_tokens:
- id: weather-reporter
token_env: WEATHER_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
pipelines:
- id: weather-daily
source:
backend: http_upload
staging_path: /var/spool/distributor/weather-daily
max_upload_size: 20MB
destinations:
- id: archive
backend: local
path: /srv/reports/archive
```
`upload_tokens` is required when any pipeline source uses `http_upload`. Each token record resolves its bearer token value from the process environment or `secrets.directory`. `allow_pipelines` lists configured upload pipeline ids that the token may submit to. One token may authorize multiple upload pipelines, and multiple tokens may authorize the same upload pipeline.
For `http_upload` sources, `staging_path` defaults to `<server.http.staging_root>/<pipeline id>`. `max_upload_size` defaults to `server.http.max_upload_size`.
## Top-Level Fields
### `server.http`
`server.http` controls the HTTP upload server used by `serve`.
- `bind`: optional TCP bind address. Default: `127.0.0.1:8080`.
- `staging_root`: optional root used to default `http_upload` source staging paths. Default: `/var/spool/distributor`.
- `max_upload_size`: optional default upload limit for HTTP upload sources. Default: `20MB`.
- `queue_size`: optional upload admission queue size. Default: `16`.
- `max_concurrency`: optional upload worker concurrency. Default: `1`.
- `retention`: optional in-memory completed-run retention duration. Default: `24h`.
Numeric server values and durations must be greater than zero after defaults are applied.
### `secrets`
- `directory`: optional directory of secret files used by the config-owned credential resolver.
See [Secrets](#secrets) for resolution rules.
### `upload_tokens`
`upload_tokens` configures bearer tokens for `distributor serve`. It is required when any pipeline source backend is `http_upload` and is invalid when no upload pipelines are configured.
Each token has:
- `id`: required unique slug-like identifier for the token record. It must start with a letter or number and may contain letters, numbers, `.`, `_`, and `-`.
- `token_env`: required environment variable or secret-file name containing the bearer token value.
- `allow_pipelines`: required non-empty list of configured pipeline ids whose source backend is `http_upload`.
Token values must resolve to non-empty strings and must be unique across token records. Every configured upload pipeline must be allowed by at least one token.
### `pipelines`
`pipelines` is required and must contain at least one pipeline.
Each pipeline has:
- `id`: required unique slug-like identifier. It must start with a letter or number and may contain letters, numbers, `.`, `_`, and `-`.
- `source`: required source backend config.
- `validation`: optional validation policy.
- `destinations`: required non-empty destination list.
Pipeline ids must be unique across the config.
## Backend Reference
### Local Backend
Local backends can be used as sources and destinations.
```yaml
backend: local
path: /srv/distributor/archive
```
- `backend`: required value `local`.
- `path`: required local filesystem root for this backend.
### SSH/SFTP Backend
SSH backends use native SFTP and can be used as sources and destinations. Adapter protocol behavior is documented in [SSH/SFTP Integration](integrations/ssh-sftp.md).
```yaml
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
```
- `backend`: required value `ssh`.
- `host`: required SSH host.
- `path`: required remote root path.
- `user`: optional SSH username. If omitted, the adapter uses the current OS user when available.
- `port`: optional TCP port. Default: `22`.
- `ssh_key_file`: optional private key path.
- `known_hosts`: optional OpenSSH `known_hosts` path.
- `host_key_policy`: optional host key policy. Default: `accept-new`.
Accepted host key policy values:
- `strict` or boolean `true`: require a matching known host key.
- `accept-new`: accept and persist a new host key, but reject changed known keys.
- `off` or boolean `false`: disable host key checking.
Authentication uses SSH agent identities when `SSH_AUTH_SOCK` is available, then `ssh_key_file` when configured. Password authentication is not configured in YAML.
### S3-Compatible Backend
S3 backends can be used as sources and destinations. Adapter protocol behavior is documented in [S3-Compatible Storage Integration](integrations/s3.md).
```yaml
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
```
- `backend`: required value `s3`.
- `endpoint`: required S3-compatible endpoint URL.
- `bucket`: required bucket name.
- `prefix`: optional backend root prefix. Leading and trailing slashes are trimmed; the remaining value must be a clean relative slash-separated path.
- `region`: optional region. Default: `us-east-1`.
- `force_path_style`: optional addressing mode toggle. Default: `true`.
- `credentials.access_key_id_env`: optional environment variable or secret-file name for the access key id.
- `credentials.secret_access_key_env`: optional environment variable or secret-file name for the secret access key.
The S3 credential variable names must either both be configured or both be omitted. When omitted, the AWS SDK default credential chain is used. When configured, both values must resolve to non-empty strings through the process environment or `secrets.directory`.
### HTTP Upload Source Backend
HTTP upload backends are valid only as pipeline sources and are served by `distributor serve`. The API contract is documented in [HTTP Upload API Contract](integrations/http-upload.md).
```yaml
backend: http_upload
staging_path: /var/spool/distributor/weather-daily
max_upload_size: 20MB
```
- `backend`: required value `http_upload`.
- `staging_path`: optional staging path. Default: `<server.http.staging_root>/<pipeline id>`.
- `max_upload_size`: optional per-source upload limit. Default: `server.http.max_upload_size`.
## Validation Policy
```yaml
validation:
on_digest_mismatch: fail
```
- `validation.on_digest_mismatch`: optional. Default and only accepted value: `fail`.
Source bundle digest mismatches fail validation before destination writes occur. The manifest file-format contract is documented in [Source Bundle Contract](integrations/source-bundle.md).
## Destination Fields
Each destination embeds a backend config at the destination level and may also configure publishing, transforms, path mapping, links, and transfer behavior.
```yaml
destinations:
- id: archive
backend: local
path: /srv/reports/archive
publish:
source: true
html: false
path_mapping:
mode: preserve_relative
transfer:
on_destination_same: skip
on_destination_older: replace
on_destination_newer: skip
on_conflict: fail
```
- `id`: required unique slug-like identifier within the pipeline.
- Backend fields: required according to the selected destination backend.
- `publish`: optional publish policy. Default: source-only publication.
- `transform`: required only when publishing generated HTML.
- `path_mapping`: optional destination path mapping policy.
- `links`: optional public URL metadata policy.
- `transfer`: optional destination reconciliation policy.
Destination ids must be unique within a pipeline.
## Publish And Transform Policy
### Source-Only Publication
```yaml
publish:
source: true
html: false
```
`publish.source` controls whether source manifest files are copied to the destination.
### Markdown-To-HTML Publication
```yaml ```yaml
publish: publish:
@@ -66,209 +306,106 @@ transform:
mode: sidecar mode: sidecar
``` ```
Sidecar generation writes `report.html` for `report.md`. It does not mutate the source bundle. `publish.html` controls whether generated HTML outputs are published. When `publish.html` is `true`, `transform.markdown_to_html.enabled` must also be `true`.
To publish a single Markdown file as `index.html`: Markdown transform fields:
```yaml - `transform.markdown_to_html.enabled`: enables Markdown-to-HTML generation for this destination.
publish: - `transform.markdown_to_html.mode`: optional. Accepted values are `sidecar` and `index`; default is `sidecar` when a Markdown transform block is present.
source: false - `transform.markdown_to_html.input`: optional source manifest path for `index` mode only.
html: true
transform:
markdown_to_html:
enabled: true
mode: index
input: report.md
```
When `mode: index` omits `input`, the source manifest must list exactly one Markdown file. `sidecar` mode renders every manifest-listed `.md` file to a same-directory `.html` output. `index` mode renders one Markdown source to `index.html` at the destination bundle path. If `index` mode omits `input`, the selected source bundle must contain exactly one Markdown file.
At least one output type must be enabled. Enabled Markdown transforms are rejected when `publish.html` is `false`, and `input` is rejected unless `mode` is `index`.
## Destination Path Mapping ## Destination Path Mapping
Each destination chooses how source bundle paths map into that destination:
```yaml ```yaml
path_mapping: path_mapping:
mode: preserve_relative mode: preserve_relative
``` ```
`preserve_relative` is the default. It publishes each discovered source bundle at the same path relative to the destination backend root. A source bundle at `daily/2026-06-01` publishes below `daily/2026-06-01` for that destination. - `path_mapping.mode`: optional. Accepted values are `preserve_relative` and `fixed`; default is `preserve_relative`.
`fixed` publishes one selected source bundle directly at the destination backend root: `preserve_relative` publishes each discovered source bundle at the same path relative to the destination backend root.
```yaml `fixed` publishes one selected source bundle directly at the destination backend root. Fixed destinations select the newest discovered source bundle by manifest `created` timestamp. If multiple bundles have the same timestamp, the source-root-relative bundle path in ascending order wins.
destinations:
- id: latest-html
backend: local
path: /srv/www/reports/latest
path_mapping:
mode: fixed
publish:
source: false
html: true
transform:
markdown_to_html:
enabled: true
mode: index
input: report.md
```
Fixed destinations select the newest discovered source bundle by manifest `created` timestamp. If multiple candidates have the same timestamp, the source-root-relative bundle path in ascending order wins. Older candidates are not planned or written for that destination. Fixed mapping is useful for stable latest-style paths. Preview fixed destinations with `run --dry-run`, especially before using `--force`.
Fixed mapping is useful for stable latest-style paths. It is more destructive than archive-style publication because successive source bundles target the same destination root. Preview fixed destinations with `run --dry-run`, especially before using `--force`.
## Destination Links ## Destination Links
Destinations can record public URLs for published outputs:
```yaml ```yaml
links: links:
base_url: https://reports.example.com/archive base_url: https://reports.example.com/archive
primary: auto primary: auto
``` ```
`links.base_url` is an absolute `http` or `https` URL corresponding to the destination backend root. It may include a path prefix, but it must not include a query string or fragment. Distributor does not infer public URLs from backend config. - `links.base_url`: required when `links` is present. It must be an absolute `http` or `https` URL with a host and no query string or fragment.
- `links.primary`: optional. Accepted values are `auto`, `html`, and `source`; default is `auto` when `links` is present.
`links.primary` selects the top-level primary URL stored in destination state: `distributor` does not infer public URLs from backend config. Destination state URL fields are documented in [Destination State Contract](integrations/destination-state.md). Output URLs are built from `links.base_url`, the destination bundle path, and output paths using URL path semantics. `index.html` outputs produce directory-style URLs that omit the filename.
Primary URL policies:
- `auto`: prefer `index.html`, then generated HTML, then source outputs. - `auto`: prefer `index.html`, then generated HTML, then source outputs.
- `html`: use the first generated HTML output. - `html`: use the first generated HTML output.
- `source`: use the first copied source output. - `source`: use the first copied source output.
If a destination has no `links` block, no URL metadata is generated. If a primary policy has no matching output, per-output URLs are still recorded and the top-level primary URL is omitted. If no output matches the primary policy, per-output URLs may still be recorded and the top-level primary URL is omitted.
Output URLs are built from `links.base_url`, the destination bundle path, and the output path using URL path semantics. `index.html` outputs produce directory-style URLs that omit the filename. ## Transfer Policy
## 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.
- `path_mapping.mode`: optional; defaults to `preserve_relative`. Accepted values are `preserve_relative` and `fixed`.
- `links.base_url`: optional links block; when present, `base_url` is required and must be an absolute HTTP or HTTPS URL without query string or fragment.
- `links.primary`: optional; defaults to `auto`. Accepted values are `auto`, `html`, and `source`.
- `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 ```yaml
backend: ssh transfer:
host: example.com on_destination_same: skip
user: distributor on_destination_older: replace
port: 2222 on_destination_newer: skip
path: /remote/root on_conflict: fail
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. Transfer fields and accepted values:
Host key policies: - `transfer.on_destination_same`: `skip` or `fail`. Default: `skip`.
- `transfer.on_destination_older`: `replace` or `fail`. Default: `replace`.
- `transfer.on_destination_newer`: `skip`, `replace`, or `fail`. Default: `skip`.
- `transfer.on_conflict`: `fail` or `replace`. Default: `fail`.
- `strict`, `true`, and `"true"` require a matching known host key. `replace` for `on_destination_newer` and `on_conflict` is honored only when `run --force` is supplied. There is no config field that enables forced replacement by default.
- `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. ## Size And Duration Values
## S3 Backend Upload size fields must be YAML strings with an integer and one of these suffixes:
S3 uses the AWS SDK for Go v2 and supports S3-compatible endpoints: - `B`
- `KB`
- `MB`
- `GB`
```yaml Suffix multipliers use powers of 1024. Values must be greater than zero after defaults are applied.
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. Duration fields must be YAML strings accepted by Go duration parsing, such as `24h`, `90m`, or `168h`. Values must be greater than zero after defaults are applied.
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`.
Markdown-to-HTML transform:
- `transform.markdown_to_html.enabled`: enables Markdown-to-HTML generation for destinations with `publish.html: true`.
- `transform.markdown_to_html.mode`: optional; defaults to `sidecar`. Accepted values are `sidecar` and `index`.
- `transform.markdown_to_html.input`: optional source manifest path for `index` mode. It must identify a listed Markdown file.
`sidecar` mode renders each manifest-listed `.md` file to a same-directory `.html` output. `index` mode renders one selected Markdown file to `index.html` at the destination bundle path. Enabled Markdown-to-HTML config is rejected when `publish.html` is false, and `input` is valid only with `mode: index`.
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
Defaults are applied after YAML decoding and before validation: Defaults are applied after YAML decoding and before validation:
- `server.http.bind: 127.0.0.1:8080`
- `server.http.staging_root: /var/spool/distributor`
- `server.http.max_upload_size: 20MB`
- `server.http.queue_size: 16`
- `server.http.max_concurrency: 1`
- `server.http.retention: 24h`
- `validation.on_digest_mismatch: fail` - `validation.on_digest_mismatch: fail`
- SSH `port: 22` - SSH `port: 22`
- SSH `host_key_policy: accept-new` - SSH `host_key_policy: accept-new`
- S3 `region: us-east-1` - S3 `region: us-east-1`
- S3 `prefix`: leading and trailing slashes trimmed
- S3 `force_path_style: true` - S3 `force_path_style: true`
- `transform.markdown_to_html.mode: sidecar` when a Markdown-to-HTML transform block is present and mode is omitted - `http_upload` source `staging_path: <server.http.staging_root>/<pipeline id>`
- `publish.source: true` - `http_upload` source `max_upload_size: server.http.max_upload_size`
- `publish.html: false` - `publish.source: true` and `publish.html: false`
- `transform.markdown_to_html.mode: sidecar` when a Markdown transform block is present and mode is omitted
- `path_mapping.mode: preserve_relative` - `path_mapping.mode: preserve_relative`
- `links.primary: auto` when a `links` block is present and `primary` is omitted - `links.primary: auto` when a `links` block is present and `primary` is omitted
- `transfer.on_destination_same: skip` - `transfer.on_destination_same: skip`
@@ -285,24 +422,31 @@ secrets:
directory: /run/secrets/distributor 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. Each regular file in the directory becomes an internal credential environment value named by the filename. Valid filenames match `[A-Za-z_][A-Za-z0-9_]*`. Directories are ignored. 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. Credential resolution checks the real process environment first, then `secrets.directory`. If both define the same name with different values, `run` emits 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 process environment variables.
S3 credentials may name environment variables: Fields resolved through this resolver:
- `credentials.access_key_id_env` - `credentials.access_key_id_env`
- `credentials.secret_access_key_env` - `credentials.secret_access_key_env`
- `upload_tokens[].token_env`
## Examples ## Maintained Examples
Maintained examples live under [examples](../examples/): Maintained examples live under [examples](../examples/). Config tests load every file listed here.
- `local-to-local.yml`: minimal local config. Local examples:
- `local-publish.yml`: runnable local source publication.
- `local-html.yml`: runnable local HTML publication. - `local-to-local.yml`: minimal local-to-local config using absolute sample paths; load-tested, but paths should be adapted before running.
- `local-index.yml`: runnable local `index.html` publication. - `local-publish.yml`: runnable local source publication used by the README quickstart.
- `fan-out.yml`: runnable local fan-out publication to source and HTML destinations. - `local-html.yml`: local sidecar HTML publication.
- `archive-and-latest.yml`: runnable local fan-out publication to an archive destination and a fixed latest destination. - `local-index.yml`: local `index.html` publication.
- `ssh-destination.yml`: environment-gated local-to-SSH publication example. - `fan-out.yml`: local fan-out publication to source and HTML destinations.
- `s3-destination.yml`: environment-gated local-to-S3 publication example. - `archive-and-latest.yml`: local archive plus fixed latest publication.
- `http-upload-local.yml`: local HTTP upload server config; requires `DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN` in the process environment or as a secret-file name before running `serve`.
Environment-gated remote examples:
- `ssh-destination.yml`: local-to-SSH publication; replace host, user, path, key, and known-host values for an SSH/SFTP endpoint you control.
- `s3-destination.yml`: local-to-S3 publication; replace endpoint, bucket, prefix, region, and credential variable names for an S3-compatible service you control.

136
docs/consumers/api.md Normal file
View File

@@ -0,0 +1,136 @@
# Upstream Producer Integration
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
## Required Inputs
The upstream application needs these values from deployment or operator configuration:
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
- upload token: bearer token that authenticates the producer;
- pipeline id: configured `http_upload` pipeline that should process this upload;
- generated files: regular local files to include in the source bundle;
- bundle id: stable identifier for the logical report stream or artifact;
- idempotency key: unique key for one producer run, reused only when retrying that same run.
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
## Recommended Workflow
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
Add the dependency from the upstream application:
```sh
go get gitea.maximumdirect.net/eric/distributor
```
## Minimal Go Example
```go
package reports
import (
"context"
"errors"
"fmt"
"os"
"time"
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
)
func SubmitReport(reportPath, summaryPath string) error {
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
if endpoint == "" || token == "" {
return fmt.Errorf("distributor endpoint and token are required")
}
pipelineID := "weather-hourly"
reportID := "weather.hourly.brentwood"
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: endpoint,
Token: token,
})
if err != nil {
return err
}
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: pipelineID,
ID: reportID,
IdempotencyKey: reportID + "." + runID,
Files: []bundle.BundleFile{
{SourcePath: reportPath, Path: "report.md"},
{SourcePath: summaryPath, Path: "summary.txt"},
},
})
if err != nil {
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
return err
}
fmt.Printf("distributor accepted run %s\n", result.RunID)
return nil
}
```
## Producer Responsibilities
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`.
## Idempotency And Status
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
Optional status check:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
## References
In the `distributor` source tree:
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.

View File

@@ -0,0 +1,90 @@
# `pkg/bundle`
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
Import path:
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
## Preferred Complete-Bundle Workflow
Use `WriteBundle` when producer-generated files live outside the final bundle root.
```go
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
ID: "weather.hourly.brentwood",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = manifest
```
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
## Existing Bundle Root Workflow
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
```go
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
Root: root,
ID: "weather.hourly.brentwood",
Files: []string{"report.md", "summary.txt"},
})
if err != nil {
return err
}
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
return err
}
if err := bundle.ValidateBundle(root, manifest); err != nil {
return err
}
```
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
## Paths And Ordering
Bundle paths are slash-separated paths relative to the bundle root.
Invalid paths include:
- empty paths;
- absolute paths;
- paths containing backslashes;
- `.` or `..` path segments;
- empty path segments;
- any basename of `manifest.json` or `.distributor.json`.
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
## Validation And Digest Helpers
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
Useful helpers:
- `LoadManifest`: read `manifest.json` from a bundle root.
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
- `ValidateManifest`: validate manifest-only semantics.
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
## Boundaries
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.

View File

@@ -0,0 +1,122 @@
# `pkg/upload`
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
Import path:
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
```
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
`UploadFiles` examples also use:
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
## Client Construction
```go
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: "https://distributor.example.com",
Token: token,
})
if err != nil {
return err
}
```
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
## Upload Producer Files
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
```go
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: "weather-hourly",
ID: "weather.hourly.brentwood",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
## Upload An Existing Bundle
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
```go
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
PipelineID: "weather-hourly",
Root: "/var/spool/weather/hourly-2026-06-07T15",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
## Result And Status
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
Poll status while the server retains the in-memory run record:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
## Idempotency And Retry
Every upload request includes `Idempotency-Key`.
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
The client retries only safe cases:
- `503 Service Unavailable`;
- temporary network errors;
- ambiguous mid-upload failures.
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
Detect conflicting key reuse with `errors.As`:
```go
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
```
## Boundaries
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.

View File

@@ -0,0 +1,99 @@
# Destination State Contract
Audience: operators, integrators, and maintainers who inspect or reason about destination `.distributor.json` files.
Each managed destination bundle path contains `.distributor.json`. This file is the destination sentinel and state record used for comparison, skip, replacement, and recovery decisions.
## State Schema
Current schema version: `1`.
```json
{
"schema_version": 1,
"distributor_version": "dev",
"pipeline_id": "reports",
"destination_id": "archive",
"published_at": "2026-06-04T12:00:00Z",
"source": {
"manifest": {
"schema_version": 1,
"id": "reports.example.2026-06-04",
"digest": "sha256:...",
"created": "2026-06-04T11:55:00Z",
"files": [
{"path": "report.md", "sha256": "sha256:...", "size": 1234}
]
}
},
"links": {
"primary_url": "https://reports.example.com/archive/report.html"
},
"outputs": [
{
"path": "report.html",
"kind": "generated",
"source_path": "report.md",
"transform": "markdown_to_html",
"url": "https://reports.example.com/archive/report.html",
"sha256": "sha256:...",
"size": 2345
}
]
}
```
Required fields:
- `schema_version`: must be `1`.
- `pipeline_id`: configured pipeline id that wrote the state.
- `destination_id`: configured destination id that wrote the state.
- `published_at`: RFC3339 publication timestamp.
- `source.manifest`: embedded source bundle manifest.
- `outputs`: output records array; empty is allowed, but the field is required.
Optional fields:
- `distributor_version`: application version string when available.
- `links.primary_url`: absolute HTTP or HTTPS URL selected by destination link policy.
## Output Records
Each output record has:
- `path`: destination-relative output path.
- `kind`: `source` or `generated`.
- `source_path`: source manifest path used for the output.
- `transform`: required for `generated` outputs; omitted for copied source outputs.
- `url`: optional absolute HTTP or HTTPS URL for the output.
- `sha256`: lowercase `sha256:<64 hex>` digest of the output bytes.
- `size`: output byte size, zero or greater.
Output paths must be unique and use clean relative slash-separated path rules.
## Comparison Semantics
`distributor` compares the current source manifest to destination state before writing:
- No state and no content: publish new outputs.
- No state and existing content: treat the destination as unmanaged.
- Matching embedded source manifest: skip.
- Same source id with older `created`: replace if policy allows.
- Same source id with newer `created`: skip by default.
- Same source id and same `created` with different digest: conflict.
- Different source id, pipeline id, or destination id: conflict.
- Invalid state JSON or invalid state fields: conflict.
Normal replacement deletes only managed output paths recorded in `outputs` plus `.distributor.json`. Forced replacement deletes the bounded destination bundle path.
## Boundaries
Destination state is internal managed state written by `distributor`. Operators may inspect it during recovery, but normal workflows should not edit it by hand. Source `manifest.json` is not copied as destination state.
## Tests
Before changing this contract, inspect and run:
```sh
go test ./internal/state ./internal/publish
```

View File

@@ -0,0 +1,152 @@
# HTTP Upload API Contract
Audience: producers, operators, and maintainers integrating with `distributor serve`.
`distributor serve` exposes a local HTTP upload API for pipelines whose source backend is `http_upload`. Bearer tokens authenticate producers, and the upload path selects the configured pipeline. The selected token must be allowed for the requested pipeline.
## Authentication
Uploads authenticate with:
```text
Authorization: Bearer <token>
```
Token values are resolved from top-level `upload_tokens` records through the process environment or `secrets.directory`. Tokens are not configured as YAML literal values.
Requests that include `pipeline` or `pipeline_id` query parameters are rejected. Use the pipeline id in the upload path.
## Endpoints
### `GET /healthz`
Returns `200 OK` when the server is running:
```json
{"status":"ok"}
```
### `POST /v1/pipelines/{pipeline_id}/upload`
Accepts one source bundle archive and returns after the archive is staged and validated.
Producers may include:
```text
Idempotency-Key: <key>
```
`pipeline_id` must name a configured pipeline whose source backend is `http_upload`, and the authenticated token must allow that pipeline. Idempotency keys are scoped to token id, pipeline id, and key. Valid keys are non-empty ASCII strings up to 128 bytes using letters, digits, `.`, `_`, `-`, and `:`. Invalid keys return `400`.
Accepted content types:
- `application/x-tar`
- `application/gzip`
- `application/x-gzip`
Successful admission returns `202 Accepted`:
```json
{"run_id":"reports.20260604T120000Z.abcdef12","status":"accepted"}
```
Common error responses:
- `400`: pipeline query supplied, invalid idempotency key, archive rejected, malformed archive, or invalid staged source bundle.
- `401`: missing, empty, or unknown bearer token.
- `403`: bearer token is valid but is not allowed for the requested pipeline.
- `404`: upload path is unknown or the requested upload pipeline is not configured.
- `409`: repeated idempotency key conflicts with another source manifest, or the same key is already being staged.
- `413`: upload body exceeds the selected pipeline size limit.
- `415`: unsupported content type.
- `503`: upload queue is full.
Error bodies use:
```json
{"error":"<message>"}
```
Retryable idempotency conflicts include:
```json
{"error":"upload idempotency key is already being processed","retryable":true}
```
When `Idempotency-Key` is omitted, upload admission preserves the raw HTTP behavior: every valid accepted upload receives its own run id. When a key is supplied, the server records the accepted run after archive staging and source bundle validation succeed. Reusing the same key for the same token id, pipeline id, and normalized source manifest returns the original `202 Accepted` response and does not enqueue another run. Reusing the same key for a different normalized source manifest within that scope returns `409 Conflict`. Producers should use a fresh key for each distinct producer run and reuse a key only for retries of that same run.
### `GET /runs/<run-id>`
Returns an in-memory status record while retained:
```json
{
"run_id": "reports.20260604T120000Z.abcdef12",
"pipeline_id": "reports",
"status": "succeeded",
"accepted_at": "2026-06-04T12:00:00Z",
"started_at": "2026-06-04T12:00:01Z",
"finished_at": "2026-06-04T12:00:02Z",
"report": {}
}
```
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Failed records include `error`. Succeeded and failed records may include a run report.
Unknown, malformed, expired, or process-lost run ids return `404`.
## Archive Contract
Upload archives must be uncompressed tar or gzip-compressed tar. The archive must contain exactly one root-level `manifest.json` and all manifest-listed files.
Archive entry rules:
- Paths must be clean relative slash-separated paths.
- Absolute paths, backslashes, `.` and `..` segments, duplicate files, and nested `manifest.json` entries are rejected.
- Only directories and regular files are accepted.
- Symlinks, hardlinks, devices, FIFOs, sockets, and other entry types are rejected.
The uploaded archive size and extracted bundle size are bounded by the selected pipeline's `source.max_upload_size`. Extracted file count is also bounded by the implementation.
## Go Producer Helper
Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/upload` to build or validate source bundles, package them as gzip-compressed tar archives, and submit them to this API. See [Upstream Producer Integration](../consumers/api.md) for the copyable upstream implementation guide and [`pkg/upload`](../consumers/pkg-upload.md) for package-specific workflow guidance.
```go
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: "http://127.0.0.1:8080",
Token: token,
})
if err != nil {
return err
}
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
PipelineID: "reports",
Root: "examples/source-bundle",
IdempotencyKey: "reports.example.20260604T120000Z",
})
```
`Endpoint` is the server base URL; the package derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `PipelineID` is required and selects the configured distributor workflow. `UploadBundle` validates a local bundle by default and uploads only `manifest.json` plus manifest-listed files. `UploadFiles` creates a temporary bundle from explicit `bundle.BundleFile` values before uploading. When `IdempotencyKey` is omitted, the package generates one random 128-bit lowercase hex key for the upload operation and reuses it across retries.
The helper retries only safe cases: `503 Service Unavailable`, temporary network errors, and ambiguous mid-upload failures. It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`. Bearer token values are redacted from returned errors.
## Queue And Retention
`server.http.queue_size` bounds accepted-but-not-started uploads plus uploads being staged. `server.http.max_concurrency` bounds publishing concurrency. The coordinator does not run two uploads for the same pipeline concurrently.
Completed status records expire after `server.http.retention`; expiration removes committed staged bundle directories for completed uploads. Server restart clears queue state and status records.
Idempotency records are memory-only, expire with completed upload status records, and are cleared by server restart.
## Boundaries
The HTTP API does not expose pipeline selection by query parameter, TLS, public routing policy, or durable status storage. Put public access controls, TLS termination, and rate limiting in deployment infrastructure.
## Tests
Before changing this contract, inspect and run:
```sh
go test ./internal/app ./internal/ingest ./pkg/upload
```

View File

@@ -1,27 +1,27 @@
# Markdown Integration # Markdown Integration
## Purpose Audience: operators and maintainers who rely on generated HTML outputs from Markdown source files.
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. Markdown-to-HTML is an implemented file-format integration used by destination transform policy.
## Dependency ## 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. Rendering uses `github.com/yuin/goldmark`. The exact version is pinned in `go.mod`.
## Renderer behavior ## Renderer Behavior
`internal/transform/markdown.New` constructs the renderer with `goldmark.New()` and no project-specific extensions or renderer options. The transformer constructs `goldmark.New()` with no project-specific extensions, parser options, renderer options, templates, CSS, or metadata injection.
The transform supports two output modes: Supported output modes:
- `sidecar`: reads each source bundle file ending in `.md` 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. - `sidecar`: renders each source manifest file ending in `.md` and writes a generated output beside it with the `.md` suffix replaced by `.html`.
- `index`: renders one selected Markdown source to `index.html` at the destination bundle path. - `index`: renders one Markdown source file to `index.html` at the destination bundle path.
In `index` mode, `transform.markdown_to_html.input` can name the source manifest path to render. If `input` is omitted, the manifest must list exactly one Markdown file. The selected input must be a safe relative source path, must be listed in the source manifest, and must end in `.md`. In `index` mode, `transform.markdown_to_html.input` may name the source manifest path to render. If `input` is omitted, the source manifest must list exactly one `.md` file. The selected input must be a clean relative source path, must be listed in the source manifest, and must end in `.md`.
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. 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 ## HTML Wrapper
Rendered Markdown body HTML is wrapped in a fixed document shell: Rendered Markdown body HTML is wrapped in a fixed document shell:
@@ -33,7 +33,7 @@ Rendered Markdown body HTML is wrapped in a fixed document shell:
The wrapper is deterministic and does not read configuration, templates, CSS, or source manifest metadata. The wrapper is deterministic and does not read configuration, templates, CSS, or source manifest metadata.
## Output metadata ## Output Metadata
Generated outputs record: Generated outputs record:
@@ -43,18 +43,16 @@ Generated outputs record:
- SHA-256 digest of the wrapped HTML bytes; - SHA-256 digest of the wrapped HTML bytes;
- byte size of the wrapped HTML bytes. - byte size of the wrapped HTML bytes.
Destination state stores generated outputs with `kind: generated`, `source_path`, `transform`, `sha256`, `size`, and optional `url`.
## Boundaries ## 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. Markdown rendering does not mutate source bundles, publish files, write `.distributor.json`, select destination actions, or choose transfer policy. Publish planning decides whether generated HTML is selected for a destination and destination state records the generated output metadata.
Publish planning chooses the configured mode and input for each destination. Markdown rendering does not inspect destinations, publish files, write `.distributor.json`, or choose transfer actions.
## Tests ## Tests
Before changing Markdown renderer behavior, inspect and run: Before changing Markdown renderer behavior, inspect and run:
```bash ```sh
go test ./internal/transform/markdown go test ./internal/transform/markdown
``` ```
The tests cover sidecar naming, index input selection, ignored non-Markdown files, raw HTML handling, deterministic output, digest metadata, and size metadata.

73
docs/integrations/s3.md Normal file
View File

@@ -0,0 +1,73 @@
# S3-Compatible Storage Integration
Audience: operators and maintainers configuring S3-compatible sources or destinations.
The S3 backend uses the AWS SDK for Go v2 against a configured S3-compatible endpoint.
## Dependencies
Runtime S3 support uses:
- `github.com/aws/aws-sdk-go-v2`
- `github.com/aws/aws-sdk-go-v2/config`
- `github.com/aws/aws-sdk-go-v2/credentials`
- `github.com/aws/aws-sdk-go-v2/service/s3`
- `github.com/aws/smithy-go`
Exact versions are pinned in `go.mod`.
## Config Contract
Required fields:
- `backend: s3`
- `endpoint`
- `bucket`
Optional fields:
- `prefix`: backend root object-key prefix; leading and trailing slashes are trimmed.
- `region`: defaults to `us-east-1`.
- `force_path_style`: defaults to `true` through config defaults.
- `credentials.access_key_id_env`
- `credentials.secret_access_key_env`
Credential environment variable names must either both be configured or both be omitted. When configured, values are resolved through the process environment or `secrets.directory` before opening the backend. When omitted, the AWS SDK default credential chain is used.
## Object Key Mapping
The configured `bucket` plus optional `prefix` is the backend root. Logical storage paths are joined under that prefix using slash-separated object keys.
Prefixes and logical paths must be clean relative slash-separated paths. Prefixes are object-key prefixes, not real directories.
## Storage Behavior
The adapter uses these S3 operations:
- `HeadObject` for stat and overwrite checks.
- `GetObject` for reads.
- `PutObject` for writes.
- `ListObjectsV2` for walks and prefix deletion planning.
- `DeleteObject` for managed cleanup and replacement.
Writes buffer the input and set `ContentLength`. If no content type is supplied by the caller, the adapter infers a content type from the logical path.
Normal replacement and failed-write cleanup delete only managed output objects plus `.distributor.json`. Forced replacement deletes objects under the bounded destination bundle prefix. The backend does not manage bucket versioning, lifecycle rules, object lock, or delete markers.
## Error Mapping
S3 not-found responses are translated into shared storage not-found errors where practical. Other service, credential, network, or endpoint errors are surfaced through storage errors with backend and logical path context.
## Boundaries
The S3 integration does not create buckets, alter bucket policy, configure TLS bypass, manage public URLs, or infer website URLs from endpoint or bucket settings. Public URL metadata is configured separately with destination `links`.
## Tests
Before changing this integration, inspect and run:
```sh
go test ./internal/adapters/s3
```
Live S3-compatible tests are opt-in and gated by environment variables in the adapter test package.

View File

@@ -0,0 +1,94 @@
# Source Bundle Contract
Audience: producer developers, integrators, and maintainers who create or validate source bundles consumed by `distributor`.
A source bundle is a directory containing `manifest.json` and every regular file listed by that manifest. This is the producer-to-`distributor` file-format contract.
## Manifest Schema
Current schema version: `1`.
```json
{
"schema_version": 1,
"id": "reports.example.2026-06-04",
"digest": "sha256:...",
"created": "2026-06-04T12:00:00Z",
"files": [
{
"path": "report.md",
"sha256": "sha256:...",
"size": 1234
}
]
}
```
Required manifest fields:
- `schema_version`: must be `1`.
- `id`: non-empty bundle identifier. For replacement workflows, keep this stable for the logical source that should update the same managed destination artifact.
- `digest`: lowercase `sha256:<64 hex>` digest of the ordered `files` list.
- `created`: RFC3339 timestamp.
- `files`: non-empty ordered list of file records.
Required file fields:
- `path`: bundle-relative slash-separated file path.
- `sha256`: lowercase `sha256:<64 hex>` digest of the file bytes.
- `size`: file size in bytes, zero or greater.
## Path Rules
Manifest file paths must be clean relative slash-separated paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` segments, include empty path segments, or normalize to a different path.
Any basename of `manifest.json` or `.distributor.json` is reserved, including nested occurrences such as `nested/manifest.json`.
Listed files must be regular files. Symlinks and other special file types are rejected during local bundle validation and manifest building.
## Digest Rules
File digests use SHA-256 over each file's raw bytes.
The bundle digest is SHA-256 over the canonical JSON-like payload for the ordered file records. The payload is constructed as:
```text
[{"path":"<path>","sha256":"<sha256>","size":<size>},...]
```
File order is significant. Explicit file lists preserve caller order. Scan mode sorts paths in ascending slash-path order.
## Producer APIs
Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to build and validate this contract. See [`pkg/bundle`](../consumers/pkg-bundle.md) for producer workflow guidance.
- `BuildManifest`: builds a manifest from explicit file paths or scan mode.
- `WriteManifest`: writes `manifest.json`, optionally replacing an existing manifest.
- `WriteBundle`: copies source files into a complete bundle, validates it, and promotes it into place.
- `LoadManifest`, `ParseManifest`, `ValidateManifest`, and `ValidateBundle`: parse and validate local bundles.
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers.
Go producers that submit bundles to `distributor serve` can use `gitea.maximumdirect.net/eric/distributor/pkg/upload`. See [Upstream Producer Integration](../consumers/api.md) and [HTTP Upload API Contract](http-upload.md).
CLI producers can use:
```sh
go run ./cmd/distributor manifest create <bundle-path> --id <bundle-id>
go run ./cmd/distributor validate <bundle-path>
```
## Scan Mode
Manifest scan mode walks the local bundle root recursively, includes regular files, includes dotfiles, skips files whose basename is `manifest.json` or `.distributor.json`, rejects symlinks, and sorts paths before building the manifest.
## Boundaries
The source bundle manifest does not configure routing, destination selection, public URLs, credentials, transforms, notification behavior, or storage backends. Those concerns belong in `distributor` configuration and destination state.
## Tests
Before changing this contract, inspect and run:
```sh
go test ./pkg/bundle ./pkg/upload ./internal/bundle
```

View File

@@ -0,0 +1,71 @@
# SSH/SFTP Integration
Audience: operators and maintainers configuring SSH/SFTP sources or destinations.
The SSH backend uses native SSH and SFTP libraries. It does not call `ssh`, `scp`, or `rsync`.
## Dependencies
Runtime SSH support uses:
- `golang.org/x/crypto/ssh`
- `golang.org/x/crypto/ssh/agent`
- `golang.org/x/crypto/ssh/knownhosts`
- `github.com/pkg/sftp`
Exact versions are pinned in `go.mod`.
## Config Contract
Required fields:
- `backend: ssh`
- `host`
- `path`
Optional fields:
- `user`: defaults to the current OS user when available.
- `port`: defaults to `22`.
- `ssh_key_file`: private key path.
- `known_hosts`: OpenSSH known-hosts file path.
- `host_key_policy`: `strict`, `accept-new`, or `off`; defaults to `accept-new`.
## Authentication
Authentication methods are attempted in this order:
1. SSH agent identities when `SSH_AUTH_SOCK` is set.
2. The private key configured by `ssh_key_file`.
Password authentication is not configured in YAML. If neither an agent nor key file is available, opening the backend fails.
## Host Key Policy
- `strict`: requires a matching known host key.
- `accept-new`: accepts and persists an unknown host key, but rejects changed known keys.
- `off`: disables host key checking.
When `known_hosts` is omitted and checking is enabled, the adapter uses the current user's default OpenSSH `known_hosts` path when available. During dry runs, accepted unknown host keys are not persisted.
## Storage Behavior
The configured `path` is the backend root. All source discovery, destination paths, reads, writes, state files, and deletes operate on logical paths below that root.
The adapter rejects symlink ancestors for reads and writes. Reads require regular files. Writes create parent directories and prefer atomic temp-file-plus-rename writes when requested. Walk output is sorted through the shared storage walker.
Managed cleanup and normal replacement delete only managed output paths plus `.distributor.json`. Forced replacement deletes the bounded destination bundle path.
## Boundaries
The SSH backend does not configure passwords, jump hosts, shell commands, `rsync`, host-key bypass warnings beyond command output, or broad recursive deletion outside the destination bundle path.
## Tests
Before changing this integration, inspect and run:
```sh
go test ./internal/adapters/ssh
```
Live SSH tests are opt-in and gated by environment variables in the adapter test package.

View File

@@ -1,80 +1,69 @@
# Application Orchestration # Application Orchestration
Audience: developers and LLM coding agents changing `internal/app`.
## Purpose ## 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. `internal/app` owns top-level application use cases: run, single-pipeline run, staged-source run, validate, inspect, manifest creation, and HTTP upload serving. It coordinates config loading, secret resolution, backend construction, source discovery, destination selection, publish planning/execution, notification handoff, output projection, and upload coordination.
## Inputs and outputs ## Inputs And Outputs
`Run` accepts a context, optional config path, dry-run flag, force flag, stdout writer, output format, and optional notifier. It loads YAML config, discovers source bundles for each configured pipeline, plans each destination independently, optionally executes publish plans, writes text or JSON output when stdout is supplied, and returns an aggregated error if any destination fails. Inputs include app option structs, contexts, config paths, pipeline ids, local source roots, dry-run/force flags, output format, stdout writers, HTTP requests, and optional notifier implementations.
`Validate` and `Inspect` accept either a local path or one configured pipeline source. `Validate` discovers and validates bundles. `Inspect` writes bundle metadata and manifest file entries to stdout when provided. Outputs include `RunReport`, validate/inspect/manifest results, CLI text/JSON projections, HTTP upload responses, upload status records, and errors. Destination-scoped failures can return a partial run report plus an aggregated error; fatal setup failures return before a complete report exists.
## 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. selects source bundles for each destination according to destination path mapping;
5. opens each destination backend independently;
6. builds publish plans for the selected bundle and destination combinations;
7. prints plan lines or JSON action records and records summary counters;
8. executes publish or replacement plans unless dry-run is enabled;
9. 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.
## Run implementation
`run.go` contains the public `Run` entrypoint and the main configuration orchestration path. Package-local run helpers are grouped by responsibility:
- `run_selection.go`: destination bundle selection, path mapping decisions, and fixed-path warnings;
- `run_warnings.go`: secret and SSH warning data;
- `run_output.go`: text plan lines, JSON action records, and output projections;
- `run_summary.go`: summary counters and JSON summary records;
- `run_failures.go`: destination failure aggregation and partial-result detection;
- `run_notify.go`: notification event projection and action filtering.
These helpers remain in `internal/app` because command output, warning collection, destination failure aggregation, notifier handoff, and backend construction are app-owned orchestration concerns.
## Backend and transform wiring
The app-level backend factory registers local, SSH, and S3 backends for execution. Source and destination backend config is converted through a shared app-local open spec before adapter construction. S3 explicit credential references are resolved through the config environment resolver.
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 ## 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. `internal/app` wires packages together but does not own manifest validation rules, destination state comparison, storage path rules, publish safety policy, transform rendering, config schema validation, or backend protocol behavior.
Configured-source `Validate` and `Inspect` share source backend construction with `Run` and do not open destinations. User-facing command parsing stays in `internal/cli`. User-facing config reference stays in `docs/config.md`. External contracts live under `docs/integrations/`.
## Tests ## Config Fields Used
Before changing app orchestration, inspect tests under: The package consumes the loaded `config.Config`: `server.http`, `secrets.directory`, pipeline ids, source and destination backend fields, validation policy, publish policy, transform policy, path mapping, links, and transfer policy.
- `internal/app` Config fields are validated and defaulted by `internal/config` before app workflows use them.
- `internal/cli`
- `internal/publish`
## Invariants ## Adapters Used
- One source fans out to each destination independently. The app backend factory registers runtime storage adapters for local filesystem, SSH/SFTP, and S3-compatible storage. It resolves explicit credentials through the config-owned environment resolver before opening S3 backends.
- Destination failures do not prevent later destinations from being planned.
- Dry-run must not mutate destination storage or invoke notifications. The app layer registers default transforms, including Markdown-to-HTML, and supplies a transform resolver to publish planning. It uses `notify.Noop` when no notifier is supplied.
- Concrete backend and transform registration stays at the app layer.
- The default notifier is `notify.Noop`. ## State And Manifest Behavior
Run workflows discover and validate source bundles through `internal/bundle`. Destination state actions are prepared and written through `internal/publish` and `internal/state`; the app layer records report projections of those actions and results.
HTTP uploads stage and validate archives before enqueueing a pipeline run with a local staged source root. Go producers can use the public `pkg/upload` package to create client-side gzip tar uploads for this server contract; `internal/app` remains the server-side orchestration boundary and does not import that producer package.
Upload idempotency is owned by the upload coordinator. Optional `Idempotency-Key` values are scoped to token id, pipeline id, and key. The coordinator reserves a key while staging is in progress, records the accepted run id with the validated source manifest identity after staging succeeds, returns the original accepted record for the same scoped key and same manifest, and rejects the same scoped key with a different manifest as a conflict.
## Skip And Resume Behavior
Fan-out destinations are independent. A destination failure is recorded and does not prevent later destinations from being attempted. Dry-run builds plans and reports without destination writes, destination state writes, notifier calls, or SSH known-host persistence.
HTTP upload status is in memory. Accepted jobs move through accepted, queued, running, succeeded, or failed states and expire after configured retention. Upload idempotency records are also memory-only, expire with the completed status record for their accepted run, and are cleared by process restart.
## Failure Behavior
Runtime setup fails for config load, config validation, secret loading, or credential resolution errors. Source setup failures stop the affected run before destination planning. Destination open, planning, execution, and notification failures are recorded as destination failures where a partial result exists.
HTTP upload startup fails if upload tokens are missing, empty, or duplicated. Upload requests can fail during authentication, idempotency-key validation, content-type validation, idempotency conflict checks, queue admission, archive staging, source validation, or later publish execution.
## Tests To Inspect
- `internal/app/*_test.go`
- `internal/cli/root_test.go`
- `internal/config/*_test.go`
- `internal/ingest/*_test.go`
- `internal/publish/*_test.go`
## Architectural Invariants
- App orchestration owns wiring, not low-level policy.
- Dry-run must not write outputs, destination state, notifier events, or SSH known-host entries.
- Fan-out destinations remain independent after a destination-scoped failure.
- Secret values are never printed; warnings may name variables only.
- Upload admission stages and validates a bundle before returning a run id.
- Idempotent upload retries compare normalized source manifest identity, not archive bytes.
- Runtime backend registration remains app-owned.

View File

@@ -1,55 +1,53 @@
# Bundles # Source Bundle Internals
Audience: developers and LLM coding agents changing `internal/bundle`.
## Purpose ## Purpose
`internal/bundle` discovers and validates source bundles through the storage interface. The source manifest model, manifest parsing, manifest validation, path rules, digest calculation, and producer-side local writer come from `pkg/bundle` so producer-facing APIs and distributor validation share one manifest contract. `internal/bundle` discovers and validates source bundles through the storage abstraction. It adapts the public producer-facing source manifest contract from `pkg/bundle` to local, SSH/SFTP, S3-compatible, and test storage backends.
## Inputs and outputs ## 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. Inputs are a context, a `storage.Backend`, and a source-root prefix or bundle root path. Outputs are sorted `Bundle` records containing the source-root-relative bundle path and validated manifest.
## 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.
`pkg/bundle.ValidateDigest` is the canonical digest format validator for producer-facing and internal code. `internal/bundle.ValidateDigest` delegates to that public validator so source manifests and destination state use the same digest grammar.
## Validation
`pkg/bundle.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 in `internal/bundle` additionally checks file existence, regular-file type, file size, and per-file SHA-256 for configured storage backends.
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 ## Boundaries
Internal bundle discovery uses `internal/storage` and does not import concrete adapters. Producer-side local filesystem manifest building, complete bundle writing, and validation belong to `pkg/bundle`. CLI local path support is wired in `internal/app`. `internal/bundle` delegates manifest parsing, digest calculation, source path validation, and manifest validation to `pkg/bundle`. It does not publish files, inspect destination state, choose pipelines, or know concrete backend implementations.
## Tests The external source bundle file-format contract is documented in `docs/integrations/source-bundle.md`.
Before changing bundle behavior, inspect tests under `pkg/bundle` and `internal/bundle`. ## Config Fields Used
## Invariants The package does not read config directly. App workflows pass it storage backends that were opened from configured source fields.
- `manifest.json` is the only source bundle contract. ## Adapters Used
- Source file paths must stay relative to the bundle root.
- The top-level bundle digest is derived from manifest file records in order. The package depends only on `internal/storage.Backend`. Concrete local, SSH/SFTP, S3-compatible, and fake backends are hidden behind that interface.
- Discovery order is lexical and deterministic.
- Nested manifests are rejected. ## State And Manifest Behavior
Discovery walks recursively under the source root, finds entries whose basename is `manifest.json`, converts each manifest path to a bundle root, sorts roots, rejects nested bundle roots, and validates each bundle.
Validation reads `manifest.json`, parses it, stats each manifest-listed file, requires regular files, verifies file sizes, reads file bytes, checks per-file SHA-256 digests, and recomputes the bundle digest.
## Skip And Resume Behavior
The package has no skip or resume state. Each call performs discovery or validation from the supplied backend state.
## Failure Behavior
Failures include invalid storage prefixes, missing manifests, parse errors, nested manifests, unsafe manifest paths, non-regular files, size mismatches, digest mismatches, backend stat/read errors, and no discovered bundles.
## Tests To Inspect
- `internal/bundle/*_test.go`
- `pkg/bundle/*_test.go`
- `internal/storage/fake/*_test.go`
## Architectural Invariants
- Source manifest semantics remain owned by `pkg/bundle`.
- Discovery order is deterministic.
- Nested manifests are rejected before returning bundles.
- Source paths stay clean, relative, slash-separated, and confined to the backend root.
- Concrete adapters never leak into bundle validation logic.

View File

@@ -1,86 +1,57 @@
# Configuration Internals # Configuration Internals
Audience: developers and LLM coding agents changing `internal/config`.
## Purpose ## Purpose
`internal/config` defines YAML-backed configuration structs, defaulting, and validation for distributor pipelines. `internal/config` owns YAML config structs, config file loading, defaulting, validation, size/duration parsing, SSH/S3 normalization helpers, and the credential environment resolver.
## Inputs and outputs ## 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. Inputs are YAML files, YAML scalar values, process environment lookup functions, optional secrets directories, and `Config` values. Outputs are defaulted `Config` values, validation errors, parsed byte sizes and durations, normalized backend options, loaded secret environments, secret conflict metadata, and resolved credentials.
## Loading flow ## Boundaries
`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. The package does not open storage backends, authenticate HTTP requests, start servers, publish destinations, or execute transforms. Runtime execution support is wired by `internal/app`.
Known-field checking rejects misspelled or unknown YAML keys before defaults and validation run. The canonical user-facing config reference is `docs/config.md`.
`LoadFile` does not read secret files. `Run` loads the configured secrets directory after config validation and before backend construction. ## Config Fields Used
## Defaults The package defines all user-visible config fields: `server.http`, `secrets`, `pipelines`, source and destination backend fields, validation policy, publish policy, transform policy, path mapping, links, and transfer policy.
Defaults are applied in `ApplyDefaults`: ## Adapters Used
- pipeline validation defaults `on_digest_mismatch` to `fail`; No external storage adapters are used directly. The package exposes normalized config and credential values consumed by app-level adapter construction.
- SSH backend `port` defaults to `22`;
- SSH backend `host_key_policy` defaults to `accept-new`;
- destination publish policy defaults to source output only;
- Markdown-to-HTML mode defaults to `sidecar` when a transform block is present and mode is omitted;
- destination path mapping defaults to `preserve_relative`;
- destination link primary policy defaults to `auto` when a `links` block is present;
- `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 ## State And Manifest Behavior
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, valid destination path mapping mode, valid destination link config, and valid transfer actions. The package does not parse source manifests or destination state. It validates config values that later affect manifest validation and destination state, such as publish/transform combinations, links, transfer policy, backend roots, S3 prefix shape, and HTTP upload source settings.
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. ## Skip And Resume Behavior
`ValidatePublishTransformPolicy` is shared with publish planning so destination policy combinations are checked consistently. Publishing HTML requires an enabled Markdown-to-HTML transform in `sidecar` or `index` mode. Enabled Markdown-to-HTML config is rejected when `publish.html` is false. `input` is accepted only for enabled `index` mode. A publish policy must select source output, HTML output, or both. The package has no runtime skip or resume behavior. It provides transfer policy values that publish planning later applies to destination comparison outcomes.
Destination path mapping accepts `preserve_relative` and `fixed`. The app layer applies the mapping when it selects destination bundle paths; config owns only YAML shape, defaulting, and validation. ## Failure Behavior
Destination links are optional. When a `links` block is present, `base_url` is required, must use `http` or `https`, and must not include a query string or fragment. `primary` accepts `auto`, `html`, and `source`. `LoadFile` wraps file open, YAML parse, and validation failures with config path context. YAML decoding rejects unknown fields. Validation collects all detected field errors into a single error value.
## Executable support boundary Secret loading fails for unreadable secrets directories, invalid secret filenames, unreadable secret files, and missing or empty required credential values. Secret conflicts are returned as warnings metadata, not secret values.
Config validation accepts `local`, `ssh`, and `s3` backend shapes. Runtime execution opens all three through `internal/app`. ## Tests To Inspect
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/load_test.go`
- `internal/config/validate_test.go` - `internal/config/validate_test.go`
- example-loading coverage in `internal/config` - `internal/config/secrets_test.go`
- user-facing examples under `examples/` - `internal/config/backend_view_test.go`
- `internal/app/runtime_test.go`
- example configs under `examples/`
## Invariants ## Architectural Invariants
- Defaults are applied before validation. - Defaults are applied before validation.
- Unknown YAML fields are rejected. - Unknown YAML fields are rejected.
- `docs/config.md` remains the canonical user-facing config reference. - `http_upload` is source-only config.
- Runtime backend execution support is not inferred from config validation support. - Credential-consuming runtime code must use the config-owned environment resolver.
- New user-visible config behavior must be covered by tests and docs in the same change. - Secret values are never printed by config warnings.
- New user-visible config behavior must update `docs/config.md` and tests.

51
docs/internal/ingest.md Normal file
View File

@@ -0,0 +1,51 @@
# Ingestion Internals
Audience: developers and LLM coding agents changing `internal/ingest`.
## Purpose
`internal/ingest` validates upload content types, extracts uploaded source bundle archives into local temporary storage, validates extracted bundles, and commits accepted bundles to per-run staging directories.
## Inputs And Outputs
Inputs are a context, upload body reader, content type, pipeline staging path, run id, maximum uploaded size, maximum extracted size, and maximum file count. Output is a `StagedBundle` containing the committed local bundle root and parsed manifest.
## Boundaries
The package does not authenticate HTTP requests, manage upload queues, track upload status, publish destinations, load config, or start an HTTP server. Those responsibilities live in `internal/app`.
The HTTP API contract is documented in `docs/integrations/http-upload.md`.
## Config Fields Used
The package does not read config directly. The app layer passes effective values derived from `source.staging_path`, `source.max_upload_size`, and HTTP server defaults.
## Adapters Used
The package uses the local filesystem directly for temporary archive storage, extraction, validation, and final staging path promotion. It does not use the storage backend abstraction.
## State And Manifest Behavior
Accepted archives must contain exactly one root-level `manifest.json`. After extraction, the package validates the staged root through `pkg/bundle`, including manifest parsing, source path rules, file existence, regular-file checks, file sizes, file SHA-256 digests, and bundle digest.
## Skip And Resume Behavior
The package has no resume behavior. A successful call commits one complete staged bundle root. Failed calls remove temporary data created by that call.
## Failure Behavior
Failures include unsupported content type, unsafe run id, missing staging path, non-positive limits, oversize upload body, oversize extracted content, too many files, unsafe archive paths, duplicate files, nested manifests, unsupported tar entry types, gzip/tar read errors, bundle validation errors, and filesystem errors.
## Tests To Inspect
- `internal/ingest/archive_test.go`
- `internal/app/upload_*_test.go`
- `pkg/bundle/*_test.go`
## Architectural Invariants
- Invalid archives never commit a staged root.
- Archive paths remain clean relative slash-separated paths.
- Only directories and regular files are accepted from tar archives.
- Source bundle validation happens before final staging path promotion.
- Upload authentication and queueing remain outside this package.

View File

@@ -1,27 +1,46 @@
# Link URL Policy # Link URL Policy
Audience: developers and LLM coding agents changing `internal/link`.
## Purpose ## Purpose
`internal/link` defines shared validation for configured and persisted HTTP link URLs. `internal/link` owns shared validation for configured and persisted HTTP link URLs.
## Inputs and outputs ## Inputs And Outputs
Input is a URL string. Output is either nil for an accepted URL or a concise validation error that callers wrap with field context. Input is a URL string. Output is either nil for an accepted URL or a concise validation error that callers wrap with field context.
## Validation behavior
Accepted URLs must parse successfully, use `http` or `https`, include a host, and omit query strings and fragments.
## Boundaries ## Boundaries
This package validates URL shape only. It does not construct destination output URLs, choose primary URLs, infer public URLs from backend configuration, or read configuration files. The package validates URL shape only. It does not construct output URLs, choose primary URLs, infer public URLs from backend configuration, parse config files, or write destination state.
## Tests ## Config Fields Used
Before changing link URL policy, inspect tests under `internal/link` and callers in `internal/config`, `internal/state`, and `internal/publish`. The package does not read config directly. `internal/config` uses it to validate `links.base_url`; `internal/state` uses it to validate persisted `links.primary_url` and output `url` fields.
## Invariants ## Adapters Used
- Configured `links.base_url`, persisted `links.primary_url`, persisted output `url`, and publish link planning use the same URL policy. None.
## State And Manifest Behavior
Destination state URL fields and configured link URLs share the same URL validation policy. Source manifests are not involved.
## Skip And Resume Behavior
None.
## Failure Behavior
Validation rejects parse failures, non-HTTP(S) schemes, missing hosts, query strings, and fragments.
## Tests To Inspect
- `internal/link/url_test.go`
- callers in `internal/config`, `internal/state`, and `internal/publish`
## Architectural Invariants
- Configured and persisted link URLs use one validation policy.
- URL construction remains outside `internal/link`.
- Callers own field-specific error context. - Callers own field-specific error context.
- URL path construction remains in `internal/publish`.

View File

@@ -1,35 +1,48 @@
# Notify # Notification Internals
Audience: developers and LLM coding agents changing `internal/notify` or app notification wiring.
## Purpose ## Purpose
`internal/notify` defines the internal notification interface used by the application runner. `internal/notify` defines the notification interface used by app orchestration after successful destination publication or replacement.
## Inputs and outputs ## 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. Input is a context and notification event containing pipeline id, destination id, bundle id, bundle path, action, and output metadata. Output is an error that app orchestration can record as a destination-scoped failure.
## 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 ## Boundaries
External notification adapters and user-facing notification configuration are outside current behavior. Only the no-op notifier exists in the repository. The package does not load config, send network requests, write destination state, publish files, or own run reporting.
## Tests ## Config Fields Used
Before changing notification behavior, inspect: None.
## Adapters Used
None.
## State And Manifest Behavior
Notification events carry output metadata projected from publish plans. The package does not inspect source manifests or destination state.
## Skip And Resume Behavior
Dry-run, skipped destinations, failed destinations, and planning failures do not notify. The no-op notifier has no durable state.
## Failure Behavior
`notify.Noop` returns the context error when the context is canceled; otherwise it succeeds. If another notifier implementation returns an error, `internal/app` records the affected destination as failed and continues with remaining destinations where applicable.
## Tests To Inspect
- `internal/notify` - `internal/notify`
- `internal/app/run_test.go` - `internal/app/run_notify.go`
- notification coverage in `internal/app/run_test.go`
## Invariants ## Architectural Invariants
- Notifications are emitted only after successful publish or replacement execution. - Notifications occur only after successful publish or replacement execution.
- Dry-run never notifies. - Dry-run never notifies.
- Skipped and failed destinations never notify. - Skipped and failed destinations never notify.
- The default app notifier is `notify.Noop`. - The default app notifier is `notify.Noop`.

View File

@@ -1,52 +1,63 @@
# Publish # Publish Internals
Audience: developers and LLM coding agents changing `internal/publish`.
## Purpose ## Purpose
`internal/publish` plans and executes publication for one validated source bundle and one destination. `internal/publish` plans and executes publication for one validated source bundle and one destination bundle path. It owns destination comparison mapping, output selection, URL planning, managed cleanup selection, replacement safety, and destination state projection.
## Inputs and outputs ## Inputs And Outputs
Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, publish policy, transform policy, optional link policy, transformer resolver, transfer policy, path mapping mode, destination bundle path, existing destination state, and whether explicit force was requested for the current run. Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, destination bundle path, path mapping mode, publish policy, transform policy, optional link policy, transformer resolver, transfer policy, distributor version, and force flag.
Output is a plan with an action, reason, optional primary URL, and selected source or generated outputs. Execution writes selected source files, generated files, and `.distributor.json` for publish or replacement actions. Output from planning is a `Plan` with action, reason, destination identity, selected outputs, optional existing state, optional primary URL, and force metadata. Execution writes selected source outputs, generated outputs, and `.distributor.json` for executable 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 ## Boundaries
The package publishes source files and Markdown-to-HTML outputs. Markdown sidecar mode writes same-directory `.html` outputs, and Markdown index mode writes `index.html`. Backend behavior is supplied through `internal/storage`; app runtime supplies local, SSH, and S3 backends. The package does not parse CLI flags, load config files, open concrete adapters, discover source bundles, select fixed-path bundle candidates, register transforms, or render command output. The app layer supplies validated request data and concrete dependencies.
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, load config files, or choose which source bundles a destination receives. External destination state semantics are documented in `docs/integrations/destination-state.md`.
The package owns projection from planned publish outputs to destination state output records and managed destination output paths. App JSON results and notification events keep their own schemas, but may use the publish output projection to avoid field-mapping drift. ## Config Fields Used
The app layer computes the destination bundle path before planning. `preserve_relative` destinations pass the source-root-relative bundle path. `fixed` destinations pass an empty destination bundle path, which means the destination backend root, and pass only the newest selected source bundle for that destination. The package consumes already-defaulted config values for destination `publish`, `transform`, `links`, `transfer`, and path mapping mode. It uses `config.ValidatePublishTransformPolicy` for publish/transform consistency.
When link config is present, publish planning builds per-output URLs from `links.base_url`, the destination bundle path, and each output path. `index.html` outputs use directory-style URLs. The primary URL is selected from planned outputs according to the destination primary policy. ## Adapters Used
## Safety The package depends on `internal/storage.Backend` for source and destination IO, and on a narrow transformer resolver interface for generated outputs. It does not import concrete storage adapters or concrete transform implementations.
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. ## State And Manifest Behavior
## Tests Planning inspects destination state through `internal/state`, compares it with the source manifest, and maps comparison outcomes plus transfer policy into actions: `publish_new`, `replace_older`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, or `fail_unmanaged`.
Before changing publish behavior, inspect tests under `internal/publish` and run tests under `internal/app`. Execution writes destination state after selected outputs are written. Destination state includes copied source output metadata, generated output metadata, embedded source manifest, link metadata when configured, pipeline id, destination id, and publication timestamp.
## Invariants ## Skip And Resume Behavior
- Publish planning is deterministic for the same source, destination state, policies, and transform outputs. `skip_same` and `skip_destination_newer` execute as no-ops. Normal replacement removes only managed output paths from existing state plus `.distributor.json`; this allows retries without broad deletion. Failed writes trigger cleanup of outputs written during that failed attempt where practical.
- Destination bundle paths are caller-supplied and are interpreted relative to the destination backend root.
- URL generation uses URL path semantics and does not infer public URLs from backend configuration. Forced replacement is explicit per request and deletes the bounded destination bundle path before writing new outputs and state.
## Failure Behavior
Planning fails for incomplete requests, invalid publish/transform policy, output path collisions, invalid destination state, unmanaged destination content without force, conflict outcomes not allowed by transfer policy, unresolved transforms, invalid Markdown output selection, and invalid link URL planning.
Execution fails on delete, read, transform output, write, state validation, state serialization, or context errors. Execution refuses actions that are not executable publish or replacement actions.
## Tests To Inspect
- `internal/publish/*_test.go`
- `internal/app/run_test.go`
- `internal/state/*_test.go`
- `internal/transform/markdown/*_test.go`
## Architectural Invariants
- Planning is deterministic for the same request and destination state.
- Destination bundle paths are caller-supplied and backend-root-relative.
- URL generation uses URL path semantics and never infers public URLs from backend config.
- Normal replacement deletes only managed paths recorded in existing state plus `.distributor.json`. - 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. - Forced replacement deletes only within the supplied destination bundle path.
- Publish execution writes destination state after selected outputs are written. - Destination state is written after selected outputs are written.
- Transform implementations are resolved through an interface supplied by the caller. - Transform resolution stays behind a caller-supplied interface.
- Unmanaged destination content is overwritten only by explicit forced replacement. - Unmanaged content is claimed only by explicit force.

View File

@@ -1,55 +1,54 @@
# Destination State # Destination State Internals
Audience: developers and LLM coding agents changing `internal/state`.
## Purpose ## Purpose
`internal/state` parses, validates, writes, and compares `.distributor.json` destination state. `internal/state` parses, validates, serializes, and compares `.distributor.json` destination state.
## Inputs and outputs ## 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. Inputs are destination state JSON, constructed state values, current source manifest, pipeline id, destination id, and whether the destination path has content without state. Outputs are validated state values, JSON bytes, comparison outcomes, and human-readable reasons.
## State behavior
`.distributor.json` requires:
- `schema_version: 1`
- `pipeline_id`
- `destination_id`
- `published_at`
- `source.manifest`
- `outputs`
`distributor_version` is optional diagnostic metadata. `links` is optional URL 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`. Outputs may record `url` when the destination has link generation configured.
The optional top-level `links.primary_url` records the selected primary URL for the published destination bundle. It is omitted when link generation is not configured or when the destination primary policy has no matching output.
## 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, invalid stored URLs, 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 ## Boundaries
This package does not publish files, delete files, inspect storage backends, or choose transfer policy actions. Publish planning consumes these comparison outcomes later. The package does not inspect storage backends, mutate files, choose transfer policy, build publish outputs, generate URLs, or parse config. Publish planning consumes state comparison outcomes.
## Tests The external destination state contract is documented in `docs/integrations/destination-state.md`.
Before changing destination state behavior, inspect tests under `internal/state`. ## Config Fields Used
## Invariants None directly. Destination ids, pipeline ids, and link URLs originate from config but are supplied as values by callers.
## Adapters Used
None.
## State And Manifest Behavior
`.distributor.json` schema version is `1`. Required fields are `pipeline_id`, `destination_id`, `published_at`, `source.manifest`, and `outputs`. `distributor_version` and `links` are optional.
Embedded source manifests are parsed and validated through `internal/bundle`, which delegates source manifest semantics to `pkg/bundle`. Output records require clean paths, `source` or `generated` kind, valid source paths, lowercase SHA-256 digests, non-negative sizes, and transform ids for generated outputs. Stored URLs must pass `internal/link` validation.
## Skip And Resume Behavior
Comparison is pure. It returns outcomes for absent state, unmanaged content, invalid state, pipeline/destination mismatch, same source manifest, older destination, newer destination, same-created digest conflict, and different source id conflict. It does not decide whether to skip, replace, force, or fail; publish planning maps outcomes to actions.
## Failure Behavior
Parsing rejects invalid JSON, trailing data, missing required fields, invalid timestamps, invalid embedded manifests, duplicate outputs, invalid output paths, unsupported output kinds, missing generated transforms, invalid URLs, invalid digests, and negative sizes.
## Tests To Inspect
- `internal/state/distributor_test.go`
- `internal/state/compare_test.go`
- `internal/publish/*_test.go`
## Architectural Invariants
- `.distributor.json` is the destination sentinel and state record. - `.distributor.json` is the destination sentinel and state record.
- Embedded source manifests use the same validation rules as source bundles. - Comparison does not mutate storage.
- Embedded source manifests use the source bundle contract.
- Generated outputs always record a transform id. - Generated outputs always record a transform id.
- Stored URLs are optional and must be absolute HTTP or HTTPS URLs when present. - Stored URLs are optional and must be absolute HTTP or HTTPS URLs when present.
- Comparison returns outcomes and reasons; it does not mutate storage.
- `distributor_version` is diagnostic metadata, not a comparison key. - `distributor_version` is diagnostic metadata, not a comparison key.

View File

@@ -1,75 +1,56 @@
# Storage # Storage Internals
Audience: developers and LLM coding agents changing `internal/storage`, storage adapters, or storage-backed callers.
## Purpose ## Purpose
`internal/storage` defines backend-rooted logical file access for core packages. Callers use slash-separated paths relative to a configured backend root. `internal/storage` defines backend-rooted logical file access, path validation, typed storage errors, traversal helpers, backend registration, managed deletion targets, and test fake storage behavior.
## Inputs and outputs ## 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. Inputs are contexts, logical paths or prefixes, byte slices or readers, write options, walk options, delete options, and backend open configs. Outputs are file bytes, readers, `Entry` metadata, walk callbacks, boolean content checks, registered backends, and typed errors.
Entries report a logical path, type, and size when available. Entry types are `file`, `directory`, `symlink`, and `other`.
## Boundaries ## Boundaries
Core packages should depend on `internal/storage`, not adapter packages. Adapter-specific path handling stays behind backend implementations. Core packages depend on `internal/storage`, not concrete adapters. Adapter protocol behavior belongs in `internal/adapters/local`, `internal/adapters/ssh`, and `internal/adapters/s3`; external SSH/SFTP and S3 notes live under `docs/integrations/`.
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. Runtime backend construction and registration are owned by `internal/app`. The fake backend is for tests only.
## Paths ## Config Fields Used
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. The storage package does not read config directly. App adapter wiring converts config fields into backend open config values.
## Failure behavior ## Adapters Used
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. Local, SSH/SFTP, and S3-compatible adapters implement `storage.Backend`. `internal/storage/fake` implements the same interface for tests.
Backends may wrap implementation-specific errors, but callers should receive storage errors where practical. Traversal can stop cleanly with `ErrStopWalk`. ## State And Manifest Behavior
## Traversal helpers Storage owns `.distributor.json` path helpers through `StateFileName`, `StatePath`, and `ManagedBundleTargets`. It does not parse source manifests or destination state.
Backends own their traversal mechanics. The local adapter owns filesystem walking, the SSH adapter owns SFTP directory walking, and the S3 adapter owns object listing and pagination. Logical paths are slash-separated and relative to a backend root. Prefix validation allows an empty prefix to mean the backend root; file path validation requires a non-empty path.
`internal/storage` owns the shared callback emission rules used by backends: ## Skip And Resume Behavior
- context cancellation is checked before callback emission; Storage has no publication skip policy. It supplies `HasAny` for unmanaged-content checks, `DeleteManagedBundle` target construction for normal replacement cleanup, and `DeletePrefix` semantics for explicit forced replacement.
- `WalkOptions.Limit` bounds the number of emitted entries;
- `ErrStopWalk` stops traversal without becoming a caller-visible error;
- callback errors are wrapped as storage walk errors.
`storage.HasAny(ctx, backend, prefix)` provides the shared destination-content check. It calls `Walk` with non-recursive, limit-one traversal and stops after the first emitted entry. ## Failure Behavior
## Deletion Storage errors use typed categories: not found, already exists, not empty, invalid path, conflict, permission, temporary, unsupported, and unknown. Callers should use helper predicates instead of matching strings. Traversal can stop cleanly with `ErrStopWalk`.
`DeleteManagedBundle` may delete listed managed outputs plus `.distributor.json`. ## Tests To Inspect
`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. - `internal/storage/*_test.go`
- `internal/storage/fake/*_test.go`
- `internal/adapters/local/*_test.go`
- `internal/adapters/ssh/*_test.go`
- `internal/adapters/s3/*_test.go`
## Local, SSH, S3, and fake backends ## Architectural Invariants
The local adapter maps logical paths to a configured filesystem root and keeps adapter-specific path handling behind the storage interface. - Logical paths are clean relative slash-separated paths confined to the backend root.
- Core packages never import concrete adapters.
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. - `storage.List` returns deterministic sorted entries.
- Managed deletion targets are recorded outputs plus `.distributor.json`.
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. - Prefix deletion is bounded to the requested logical prefix.
- Runtime registration remains app-owned.
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`.

View File

@@ -1,47 +1,52 @@
# Transform # Transform Internals
Audience: developers and LLM coding agents changing `internal/transform` or transform implementations.
## Purpose ## Purpose
`internal/transform` defines generated publication artifacts. `internal/transform/markdown` implements Markdown-to-HTML generation. `internal/transform` defines generated publication artifacts, transform request/response types, transform registry behavior, and transform identifiers. `internal/transform/markdown` implements Markdown-to-HTML generation.
## Inputs and outputs ## Inputs And Outputs
Inputs are a validated source bundle, source backend, and transform options supplied by publish planning. Outputs include destination path, source path, transform id, generated bytes, SHA-256, and size. Inputs are a validated source bundle, source storage backend, and transform options supplied by publish planning. Outputs are generated artifact records containing destination path, source path, transform id, generated bytes, SHA-256 digest, and byte 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 sidecar mode renders files ending in `.md` to `.html` files in the same logical directory. Markdown index mode renders one selected manifest-listed Markdown file to `index.html`. Non-Markdown files do not generate sidecar 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. Index input selection fails when the configured input is unsafe, not listed, not Markdown, or when no configured input can be inferred from exactly one manifest-listed Markdown file. Publish planning fails when HTML output is requested and the selected transform produces no outputs for a bundle.
## Boundaries ## Boundaries
Transforms do not publish files, mutate source bundles, or write destination state. Publish planning selects and writes transform outputs. Transforms do not mutate source bundles, publish files, write destination state, choose destination actions, parse config, or inspect destinations. Publish planning decides whether generated outputs are selected and writes destination state later.
The app layer owns default transform registration. The transform package does not import concrete transform implementations. The Goldmark renderer contract is documented in `docs/integrations/markdown.md`.
## Tests ## Config Fields Used
Before changing transform behavior, inspect tests under: Transform packages do not read config directly. Publish planning passes effective `transform.markdown_to_html.mode` and `transform.markdown_to_html.input` values.
- `internal/transform` ## Adapters Used
- `internal/transform/markdown`
## Invariants Transforms read source files through `internal/storage.Backend`. The Markdown implementation uses `github.com/yuin/goldmark` for rendering.
## State And Manifest Behavior
Transform outputs carry metadata later projected into destination state. Markdown sidecar mode renders manifest-listed `.md` files to same-directory `.html` outputs. Markdown index mode renders one selected Markdown source to `index.html`.
## Skip And Resume Behavior
Transforms have no skip/resume state. They are deterministic for the same source bytes and transform options.
## Failure Behavior
Registry registration fails for empty names, nil transformers, and duplicate names. Transform resolution fails when publish planning requests an unregistered transform. Markdown rendering fails on source read errors, renderer errors, unsafe configured input, missing manifest input, non-Markdown input, ambiguous index input, or absent Markdown inputs.
## Tests To Inspect
- `internal/transform/*_test.go`
- `internal/transform/markdown/*_test.go`
- `internal/publish/*_test.go`
## Architectural Invariants
- Source bundle files are never mutated by transforms. - Source bundle files are never mutated by transforms.
- Generated outputs record destination path, source path, transform id, SHA-256, and size. - Generated outputs record destination path, source path, transform id, SHA-256, and size.
- Markdown sidecar naming changes only the `.md` extension to `.html`. - Markdown sidecar naming changes only the `.md` suffix to `.html`.
- Markdown index mode always writes `index.html`. - Markdown index mode always writes `index.html`.
- Non-Markdown source files do not generate Markdown outputs. - Non-Markdown source files do not generate sidecar outputs.
- Transform registration stays outside publish planning. - Transform registration stays outside publish planning.

View File

@@ -1,333 +1,218 @@
# Distributor Operations # Distributor Operations
Audience: administrators and operators who run `distributor`, publish bundles, operate the HTTP upload service, or recover from failed runs.
This document covers operating workflows, storage layout, safety behavior, and recovery. Command syntax lives in [CLI](cli.md), configuration fields live in [Configuration](config.md), symptom-specific fixes live in [Troubleshooting](troubleshooting.md), and external contracts live under [Integrations](integrations/source-bundle.md).
## Normal Workflow ## Normal Workflow
Validate a source bundle: Validate a producer bundle before publishing:
```sh ```sh
go run ./cmd/distributor validate examples/source-bundle go run ./cmd/distributor validate examples/source-bundle
``` ```
Preview a local publication: Preview a configured run before writing destination content:
```sh ```sh
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
``` ```
Run the local publication: Publish after reviewing the preview:
```sh ```sh
go run ./cmd/distributor run --config examples/local-publish.yml go run ./cmd/distributor run --config examples/local-publish.yml
``` ```
Run the local HTML publication: Use JSON output for automation:
```sh
go run ./cmd/distributor run --config examples/local-html.yml
```
Run the local `index.html` publication:
```sh
go run ./cmd/distributor run --config examples/local-index.yml
```
Preview local fan-out publication:
```sh
go run ./cmd/distributor run --config examples/fan-out.yml --dry-run
```
Preview local archive-plus-latest publication:
```sh
go run ./cmd/distributor run --config examples/archive-and-latest.yml --dry-run
```
Preview a run for automation:
```sh ```sh
go run ./cmd/distributor run --config examples/fan-out.yml --dry-run --format json go run ./cmd/distributor run --config examples/fan-out.yml --dry-run --format json
``` ```
Preview an environment-gated SSH destination config after editing it for an SSH/SFTP endpoint you control: Use configured source diagnostics when the source is defined in YAML and may be local, SSH/SFTP, or S3-compatible storage:
```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
```
Validate one configured source without opening destinations:
```sh ```sh
go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle
go run ./cmd/distributor inspect --config examples/local-publish.yml --pipeline example-source-bundle --format json
``` ```
## Filesystem Layout Remote examples under `examples/ssh-destination.yml` and `examples/s3-destination.yml` are load-tested templates. Edit their endpoint, path, key, bucket, prefix, and credential values for storage you control before running them.
Source bundles are discovered beneath the configured source root. Each bundle is a directory containing `manifest.json`. ## Filesystem And Storage Layout
Destination bundle paths are configured per destination with `path_mapping.mode`. A source bundle is a directory containing `manifest.json` and every file listed in that manifest. See [Source Bundle Contract](integrations/source-bundle.md). Source discovery walks beneath the configured source backend root and finds bundle directories.
The default mode, `preserve_relative`, preserves 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 that destination. Each destination has its own backend root:
The `fixed` mode publishes one selected source bundle at the destination backend root. A fixed destination with local `path: /srv/www/reports/latest` writes outputs and `.distributor.json` directly under `/srv/www/reports/latest`. Fixed destinations select the newest discovered source bundle by manifest `created` timestamp, with the source-root-relative bundle path as the deterministic tie-breaker. - Local destinations use the configured local `path`.
- SSH/SFTP destinations use the configured remote `path`.
- S3-compatible destinations use the configured `bucket` plus optional `prefix`.
The maintained local examples write under `workspace/`, which is ignored by Git. Destination path mapping controls where each source bundle is published beneath the destination root:
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. - `preserve_relative` publishes each source bundle at the same source-root-relative path.
- `fixed` publishes one selected source bundle at the destination root.
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. Fixed destinations select the newest discovered source bundle by manifest `created` timestamp. If multiple bundles have the same timestamp, the source-root-relative bundle path in ascending order wins.
## Destination State Published destination bundle paths contain `.distributor.json`. See [Destination State Contract](integrations/destination-state.md). This file is both the managed sentinel and the destination state record. It records the pipeline id, destination id, publication time, source manifest, copied outputs, generated outputs, and optional public URL metadata.
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;
- optional public URL metadata when destination links are configured.
`manifest.json` from the source bundle is not copied as destination state. `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. ## Destination State And Retry Behavior
## Go Producer Bundles `distributor` compares the source manifest to destination `.distributor.json` before writing:
Go producer applications can import `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to create complete local source bundles with the same path, digest, timestamp, and validation rules used by `distributor`. The package also exposes digest helpers, including `ValidateDigest`, for producer code that needs to validate lowercase `sha256:<64 hex>` strings before writing manifests. - No destination state and no destination content: publish new outputs.
- Matching destination state: skip as already published.
- Older destination state for the same source id: replace if transfer policy allows it.
- Newer destination state: skip by default.
- Invalid destination state, identity mismatch, different source id, or same-created digest mismatch: fail by default.
- Content without `.distributor.json`: fail as unmanaged content by default.
Minimal producer-side bundle creation: Normal replacement deletes only managed output paths recorded in `.distributor.json` plus the state file, then verifies the destination bundle path is empty before writing new outputs and state.
```go If a write fails after some outputs were written, `distributor` attempts to delete outputs from that failed attempt so a retry does not treat partial outputs as unmanaged content. Operators should still inspect the destination after a failed write before retrying.
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: outputDir,
ID: "reports.example.2026-05-30",
Files: []bundle.BundleFile{
{SourcePath: reportPath, Path: "report.md"},
{SourcePath: summaryPath, Path: "summary.txt"},
},
})
if err != nil {
return err
}
```
`WriteBundle` copies local producer files into a sibling temporary directory, writes `manifest.json`, validates the result, and promotes the completed bundle into place. It fails if `Root` already exists unless `Overwrite` is true. With overwrite enabled, it builds and validates the replacement before moving the existing root aside. Fan-out destinations are independent. If one destination fails after planning or execution begins, later destinations are still attempted. The command exits non-zero if any destination failed.
Use `BuildManifest` and `WriteManifest` when a producer already wrote all bundle files into the final root. `BuildManifest` can preserve an explicit file order, or `Scan: true` can recursively include regular files under `Root` in deterministic slash-path order. Scan mode includes dotfiles, excludes files named `manifest.json` or `.distributor.json`, and rejects symlinks. ## Dry Runs And Output Review
Shell producers can create the same manifest through the CLI after writing bundle files: `run --dry-run` loads config, resolves credentials, discovers source bundles, opens destinations, inspects destination state, builds publish plans, and prints actions. It does not write outputs, `.distributor.json`, or SSH `known_hosts` entries.
```sh Review these action labels before publishing:
go run ./cmd/distributor manifest create <bundle-path> --id reports.example.2026-05-30
go run ./cmd/distributor validate <bundle-path>
```
Use repeated `--file` flags to preserve a specific file order. If no `--file` flags are provided, the command scans the bundle directory recursively using the same filtering rules as `pkg/bundle.BuildManifest`. - `publish_new`: destination is empty and unmanaged.
- `replace_older`: destination state is older than the source.
- `skip_same`: destination state already matches the source.
- `skip_destination_newer`: destination state is newer than the source and is skipped.
- `force_replace`: destructive replacement selected because `--force` is present and policy permits it.
- `error`: planning or execution failed for that destination.
## Static HTML Publication Fixed destinations add fixed-path warnings during dry runs, including the selected source bundle and replacement warnings when the destination root would be replaced.
Markdown-to-HTML publication can write sidecar files or a fixed `index.html`. JSON output includes warnings, pipeline summaries, destination action records, output records, URLs when configured, final counters, and partial failure details. Fatal setup failures such as unreadable config or invalid secrets do not produce a JSON result document.
Use sidecar mode when each Markdown source should keep a matching HTML filename: ## Forced Replacement Workflow
```yaml Use `--force` only after a dry run shows the intended bounded `force_replace` action:
publish:
source: false
html: true
transform:
markdown_to_html:
enabled: true
mode: sidecar
```
Use index mode for static-site destinations that should serve a bundle through `index.html`:
```yaml
publish:
source: false
html: true
transform:
markdown_to_html:
enabled: true
mode: index
input: report.md
```
If `input` is omitted in index mode, the source manifest must list exactly one Markdown file. Generated HTML is recorded in `.distributor.json` with `kind: generated`, `source_path`, `transform: markdown_to_html`, digest, and size metadata.
## Archive And Latest Fan-Out
A pipeline can publish the same source to an archive destination and a stable latest destination:
```yaml
pipelines:
- id: reports
source:
backend: local
path: /var/spool/distributor/reports
destinations:
- id: archive
backend: local
path: /srv/reports/archive
path_mapping:
mode: preserve_relative
publish:
source: true
html: false
- id: latest-html
backend: local
path: /srv/www/reports/latest
path_mapping:
mode: fixed
links:
base_url: https://reports.example.com/latest
primary: auto
publish:
source: false
html: true
transform:
markdown_to_html:
enabled: true
mode: index
input: report.md
```
The archive destination plans every discovered source bundle at its source-relative path. The fixed latest destination plans only the newest discovered bundle and writes `index.html` plus `.distributor.json` at its backend root.
## Static Site URLs
Use destination `links` when a destination backend root corresponds to a public HTTP or HTTPS URL:
```yaml
links:
base_url: https://reports.example.com/archive
primary: auto
```
Distributor records URLs in `.distributor.json`; it does not publish notifications or infer URLs from local, SSH, or S3 backend fields.
For archive-style destinations, URLs include the destination bundle path. A source bundle under `daily/brentwood/2026-06-01` with `base_url: https://reports.example.com/archive` can produce:
```text
https://reports.example.com/archive/daily/brentwood/2026-06-01/report.html
```
For fixed destinations, URLs are rooted at `links.base_url`. A fixed HTML index destination with `base_url: https://reports.example.com/latest` records:
```text
https://reports.example.com/latest/
```
`index.html` outputs use directory-style URLs. Other outputs include their filename. The primary URL is selected from the published outputs using the destination `links.primary` policy.
## Source Validation and Inspection
`validate` and `inspect` can operate on a local path or on one configured pipeline source. Configured source mode requires both `--config` and `--pipeline`; it loads the normal config, resolves `secrets.directory`, opens only the selected source backend, and does not open any destinations.
Configured source validation is useful when producers write directly to SSH or S3 storage:
```sh
go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
go run ./cmd/distributor inspect --config <config-path> --pipeline <pipeline-id>
```
Use `--bundle <path>` to validate or inspect one source-root-relative bundle directory:
```sh
go run ./cmd/distributor validate \
--config <config-path> \
--pipeline <pipeline-id> \
--bundle daily/2026-06-01
```
For configured SSH sources, host key and authentication behavior matches `run`. For configured S3 sources, endpoint, bucket, prefix, region, path-style, explicit credential environment variables, and `secrets.directory` handling match `run`.
## 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. Fixed path destinations add `path_mapping=fixed target=.` to planned action lines. Dry-run also prints a warning with the fixed destination candidate count and selected source bundle; destructive fixed replacements print an additional warning that the destination root would be replaced.
Use `--format json` when another process needs stable run data. JSON output includes warnings, pipeline summaries, destination actions, destination bundle paths, path mapping modes, optional link URLs, output records, final counters, and partial failure records. The summary includes `fixed_path`. If one destination fails after planning or execution begins, JSON output still contains the successful and failed destination records with `ok: false`, and the command exits non-zero.
## 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 ```sh
go run ./cmd/distributor run --config <config-path> --dry-run --force go run ./cmd/distributor run --config <config-path> --dry-run --force
go run ./cmd/distributor run --config <config-path> --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 can claim unmanaged non-empty destination paths. State conflicts require both `--force` and transfer policy that permits replacement:
Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete above that bundle path. For fixed destinations, the destination bundle path is the backend root, so forced replacement may clear that configured root but not its parent path, sibling directories, or anything outside the configured S3 bucket and prefix. Force is per run only and has no config default. - newer destination state requires `transfer.on_destination_newer: replace`;
- conflict outcomes require `transfer.on_conflict: replace`.
## Failure Handling Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete parent paths, sibling paths, or storage outside the destination bundle path. For fixed destinations, the destination bundle path is the backend root, so a forced replacement can clear that configured root.
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. `--force` applies only to the current invocation. There is no config field that enables forced replacement by default.
Errors include the pipeline id, destination id, destination backend, and bundle path where applicable. ## HTTP Upload Operation
In JSON mode, destination failures after planning or execution begins are reported in the top-level `errors` array and in the run result while preserving a non-zero exit code. Fatal setup errors such as an unreadable config or invalid secrets directory write no JSON document. The [HTTP Upload API Contract](integrations/http-upload.md) defines request and response details. `distributor serve` runs the HTTP upload API for pipelines whose source backend is `http_upload`. Top-level `upload_tokens` authenticate producers and allow one or more upload pipelines. Token values come from the process environment or `secrets.directory`, not from YAML literal values.
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. Start the maintained local example:
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. ```sh
DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN=<token> \
go run ./cmd/distributor serve --config examples/http-upload-local.yml
```
## SSH Operation Notes Readiness:
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`. ```sh
curl http://127.0.0.1:8080/healthz
```
Upload one tar or tar.gz source bundle archive:
```sh
curl -X POST http://127.0.0.1:8080/v1/pipelines/example-http-upload/upload \
-H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
-H "Content-Type: application/gzip" \
--data-binary @bundle.tar.gz
```
For safe producer retries, include an idempotency key that is stable for the same producer run and different for each distinct run:
```sh
curl -X POST http://127.0.0.1:8080/v1/pipelines/example-http-upload/upload \
-H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
-H "Content-Type: application/gzip" \
-H "Idempotency-Key: producer.run.20260604T120000Z" \
--data-binary @bundle.tar.gz
```
Go producer applications can use `pkg/upload` instead of constructing archives and HTTP requests directly. See [Upstream Producer Integration](consumers/api.md) for the copyable producer implementation guide.
The maintained example client uses the local upload server, reads the token from `DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN`, and defaults the pipeline id to `example-http-upload`. Set `DISTRIBUTOR_EXAMPLE_UPLOAD_PIPELINE_ID` or pass a second argument to use another configured upload pipeline. It generates an idempotency key by default; set `DISTRIBUTOR_EXAMPLE_UPLOAD_IDEMPOTENCY_KEY` when retrying the same producer run across separate process runs.
```sh
go run ./examples/upload-client
```
Accepted uploads return after the archive is staged and validated:
```json
{"run_id":"example-http-upload.20260604T120000Z.abcdef12","status":"accepted"}
```
Poll status while the in-memory record is retained:
```sh
curl http://127.0.0.1:8080/runs/<run-id>
```
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire after `server.http.retention`. Expiration removes committed staged bundle directories for completed uploads. Restarting the process clears upload status, queue state, and in-memory records.
Upload admission is bounded by `server.http.queue_size`. Publication concurrency is bounded by `server.http.max_concurrency`, and the coordinator does not run two uploads for the same pipeline at the same time.
`Idempotency-Key` is optional for raw HTTP clients. When present, it is scoped to the token id, pipeline id, and key. Reusing the same key with the same normalized source manifest in that scope returns the original accepted run response and does not enqueue another run. Reusing the key with a different source manifest returns `409 Conflict`. If another request with the same key is still being staged before its manifest is known, the server returns a retryable `409 Conflict`. Idempotency records are memory-only and expire with completed upload status records.
The upload server accepts `application/x-tar`, `application/gzip`, and `application/x-gzip`. Archives are extracted into a temporary staging directory, must contain exactly one root-level `manifest.json`, and must validate as one complete source bundle before a run id is issued. Per-source `max_upload_size` bounds both uploaded archive size and extracted bundle size. The implementation also caps extracted file count.
The default bind address is private loopback. Put TLS, public routing, rate limiting, and external access policy in a reverse proxy or deployment layer.
## Remote Backend Notes
### SSH/SFTP
SSH execution uses native SFTP. See [SSH/SFTP Integration](integrations/ssh-sftp.md). 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. 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. The default host key policy is `accept-new`. During dry runs, unknown host keys may be accepted for the current connection but are not persisted. Changed host keys are fatal for `strict` and `accept-new`. `host_key_policy: off` disables host key checking and should be limited to controlled test environments.
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-Compatible Storage
## S3 Operation Notes S3 execution uses the AWS SDK for Go v2. See [S3-Compatible Storage Integration](integrations/s3.md). Configure an endpoint, bucket, optional prefix, optional region, optional path-style setting, and optional explicit credential variable names.
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 S3 credential variable names are configured, both must resolve to non-empty values through the process environment or `secrets.directory`. When omitted, the AWS SDK default credential chain is used as-is.
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.
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. For fixed destinations, that prefix is the configured bucket plus optional `prefix`. Distributor does not manage bucket versioning or delete markers. ## Secrets Operation
## Secrets Directory `secrets.directory` is loaded during `run`, `serve`, and configured-source `validate` or `inspect` before credential-consuming work starts. If the directory is missing, unreadable, or contains an invalid secret filename, the command fails before storage work starts.
Configure `secrets.directory` when credential values should come from mounted files, such as deployment secrets: Real process environment values take precedence over files with the same name. If the values differ and stdout is enabled, commands emit a warning naming the ignored secret variable without printing either value. The process environment is not modified.
```yaml ## Cleanup And Recovery
secrets:
directory: /run/secrets/distributor
```
The directory is loaded during `run` and configured-source `validate` or `inspect` before any backend is opened. If the directory is missing, unreadable, or contains an invalid secret filename, the command fails before storage work starts. Use these recovery boundaries:
Real process environment values take precedence over files with the same name. If the values differ and stdout is enabled, `run` and configured-source diagnostics print a warning naming the ignored secret file variable without printing either value. The process environment is not changed. - For source validation failures, regenerate the source bundle and manifest together.
- For an empty or missing destination, rerun after fixing config or storage access.
- For unmanaged destination content, move unrelated files aside or use a different destination path before publishing.
- For failed writes, inspect the destination bundle path, remove only confirmed partial outputs if needed, then rerun `--dry-run`.
- For state conflicts, verify the source, pipeline, destination, and existing `.distributor.json` before considering `--force`.
- For HTTP upload failures, inspect `/runs/<run-id>` while retained; after expiry or restart, rely on destination state and logs/output from the publishing run.
## Caveats Do not edit `.distributor.json` during normal recovery. Treat it as the managed state record used for comparison and safe cleanup.
External notification adapters are unavailable. Force overwrite behavior is available only through the explicit `run --force` workflow. For symptom-specific fixes, see [Troubleshooting](troubleshooting.md).
For symptom-oriented fixes, see [troubleshooting](troubleshooting.md). For config details, see [configuration](config.md). For command syntax, see [CLI](cli.md).

View File

@@ -197,6 +197,7 @@ Use this current layout unless the project has a documented reason to differ:
- `cmd/distributor`: application entrypoint only. - `cmd/distributor`: application entrypoint only.
- `pkg/bundle`: public producer-facing source manifest model, digest logic, parsing, manifest building, complete local bundle writing, and local validation helpers. - `pkg/bundle`: public producer-facing source manifest model, digest logic, parsing, manifest building, complete local bundle writing, and local validation helpers.
- `pkg/upload`: public producer-facing HTTP upload client built on `pkg/bundle`.
- `internal/app`: application orchestration and top-level use cases. - `internal/app`: application orchestration and top-level use cases.
- `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring. - `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring.
- `internal/config`: configuration structs, defaults, loading, precedence, and validation. - `internal/config`: configuration structs, defaults, loading, precedence, and validation.
@@ -323,9 +324,9 @@ Important tests include:
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`. 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 component 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, public package/API 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. The source manifest and destination `.distributor.json` schemas should have canonical documentation once implemented. Producer-facing package and API workflows belong under `docs/consumers/`. Example configs should be valid and load-tested where practical.
## Non-Goals ## Non-Goals

View File

@@ -7,6 +7,7 @@ Use it with `docs/policy/architecture.md` and `docs/policy/documentation.md`.
- `cmd/distributor`: executable entrypoint only. - `cmd/distributor`: executable entrypoint only.
- `pkg/bundle`: public producer-facing source manifest and local bundle writer helpers. - `pkg/bundle`: public producer-facing source manifest and local bundle writer helpers.
- `pkg/upload`: public producer-facing HTTP upload client built on `pkg/bundle`.
- `internal/app`: top-level use cases for `run`, `validate`, and `inspect`. - `internal/app`: top-level use cases for `run`, `validate`, and `inspect`.
- `internal/cli`: standard-library command parsing, flags, help text, and command wiring. - `internal/cli`: standard-library command parsing, flags, help text, and command wiring.
- `internal/config`: YAML configuration structs, loading, defaults, and validation. - `internal/config`: YAML configuration structs, loading, defaults, and validation.
@@ -22,13 +23,13 @@ Use it with `docs/policy/architecture.md` and `docs/policy/documentation.md`.
- `internal/transform/markdown`: Markdown-to-HTML transform. - `internal/transform/markdown`: Markdown-to-HTML transform.
- `internal/notify`: notification interface and current no-op notifier. - `internal/notify`: notification interface and current no-op notifier.
- `internal/testutil`: shared test fixtures. Production code must not import this package. - `internal/testutil`: shared test fixtures. Production code must not import this package.
- `docs`: current user, operator, policy, internal, and roadmap documentation. - `docs`: current user, operator, consumer, integration, policy, internal, and roadmap documentation.
- `examples`: copyable example configs and source bundles. - `examples`: copyable example configs and source bundles.
Do not create new top-level package families such as public `pkg/...` packages Do not create new top-level package families such as public `pkg/...` packages
beyond `pkg/bundle`, generic workflow containers, or service-specific adapter beyond `pkg/bundle` and `pkg/upload`, generic workflow containers, or
directories unless the architecture policy or a current roadmap explicitly service-specific adapter directories unless the architecture policy or a
calls for them. current roadmap explicitly calls for them.
## Common Commands ## Common Commands
@@ -45,6 +46,7 @@ go test ./internal/config
go test ./internal/cli ./internal/app go test ./internal/cli ./internal/app
go test ./internal/publish ./internal/state go test ./internal/publish ./internal/state
go test ./internal/transform/markdown go test ./internal/transform/markdown
go test ./pkg/bundle ./pkg/upload
``` ```
Run the CLI against an example config: Run the CLI against an example config:
@@ -76,6 +78,7 @@ GOCACHE=/private/tmp/distributor-gocache GOMODCACHE=/private/tmp/distributor-gom
- Preserve public CLI behavior, config semantics, manifest schema, destination state schema, and implemented backend behavior unless the current task explicitly changes them. - 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 `storage.DisplayPath`, `storage.StateFileName`, `storage.StatePath`, and `storage.ManagedBundleTargets` instead of duplicating those conventions.
- Use `pkg/bundle` for normalized source manifest semantics. Internal packages should reach those rules through `internal/bundle` when they also need storage-backed bundle discovery or validation. - Use `pkg/bundle` for normalized source manifest semantics. Internal packages should reach those rules through `internal/bundle` when they also need storage-backed bundle discovery or validation.
- Keep `pkg/upload` as a producer-facing HTTP client. It should depend on `pkg/bundle` and standard HTTP/archive primitives, not on `internal/app`, `internal/ingest`, server config, storage backends, or destination state types.
- Use `config.ValidatePublishTransformPolicy` for publish and transform policy combinations. - 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 concrete transform implementations from `internal/publish`; app-level wiring owns transform registration.
- Do not import `internal/testutil` from production code. - Do not import `internal/testutil` from production code.
@@ -171,6 +174,7 @@ Test close to the behavior being changed:
- Use `internal/app` and `internal/cli` tests for user-facing workflows. - 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. - 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. - Run `go test ./...` after cross-package changes or documentation/example changes tied to tests.
- Run `go test ./pkg/bundle ./pkg/upload` after changing producer-facing bundle or upload APIs.
Live integration tests must be opt-in and skipped during normal `go test ./...` Live integration tests must be opt-in and skipped during normal `go test ./...`
unless their required environment variables are set. Test-only environment unless their required environment variables are set. Test-only environment
@@ -207,5 +211,7 @@ Follow `docs/policy/documentation.md`.
- Keep `docs/config.md` canonical for user-facing config reference. - Keep `docs/config.md` canonical for user-facing config reference.
- Keep `docs/cli.md` canonical for command syntax and workflows. - Keep `docs/cli.md` canonical for command syntax and workflows.
- Keep `docs/operations.md` canonical for operational and recovery behavior. - Keep `docs/operations.md` canonical for operational and recovery behavior.
- Keep `docs/consumers/` canonical for public package and consumer API workflows.
- Keep `docs/integrations/` canonical for external file-format and wire-protocol contracts.
- Keep `docs/internal/` focused on implemented package contracts. - 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. - Update docs in the same change as behavior when public behavior, public packages/APIs, config, CLI, examples, or internal contracts change.

View File

@@ -2,12 +2,13 @@
## Purpose ## Purpose
Project documentation must help four audiences: Project documentation must help five audiences:
1. users who need to run the application; 1. users who need to run the application;
2. administrators/operators who need to configure and operate it; 2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely; 3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants. 4. LLM coding agents that need clear scope, boundaries, and invariants;
5. developers and LLM coding agents integrating this project from another codebase.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition. Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
@@ -46,7 +47,9 @@ Canonical homes:
- CLI reference: `docs/cli.md` - CLI reference: `docs/cli.md`
- operations and recovery: `docs/operations.md` - operations and recovery: `docs/operations.md`
- troubleshooting: `docs/troubleshooting.md` - troubleshooting: `docs/troubleshooting.md`
- public API/package consumer guidance: `docs/consumers/`
- implemented internals: `docs/internal/` - implemented internals: `docs/internal/`
- external protocol, service, and file-format contracts: `docs/integrations/`
- future work: `docs/roadmap/` - future work: `docs/roadmap/`
- contributor workflow: `docs/policy/development.md` - contributor workflow: `docs/policy/development.md`
- copyable examples: `examples/` - copyable examples: `examples/`
@@ -119,6 +122,15 @@ Recommended:
- `docs/troubleshooting.md` - `docs/troubleshooting.md`
- validated examples under `examples/` - validated examples under `examples/`
### Project with public packages or consumer APIs
Required:
- `docs/consumers/api.md`
- one `docs/consumers/pkg-<name>.md` file per public package, if public packages exist
Recommended:
- copyable consumer examples under `examples/`, if practical
## Required Documents ## Required Documents
### README.md ### README.md
@@ -244,6 +256,33 @@ Each entry should include:
- safe fix; - safe fix;
- relevant links. - relevant links.
### docs/consumers/
**Audience:** developers and LLM coding agents integrating this project from another codebase
Required for projects with public packages, SDKs, client APIs, plugin APIs, or other application-facing integration surfaces.
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
`docs/consumers/api.md` should provide the consumer-facing overview and primary implementation workflow. It should include:
1. intended consumer audience and use cases;
2. required inputs supplied by operators or deployment configuration;
3. recommended public package or API workflow;
4. minimal copyable example;
5. consumer responsibilities and boundaries;
6. retry, idempotency, or status behavior, if applicable;
7. links to package-specific docs and canonical integration contracts.
Package-specific docs should be named `pkg-<name>.md` and should include:
1. import path;
2. intended use cases;
3. primary types and functions needed by consumers;
4. minimal examples;
5. validation, error, retry, and boundary behavior;
6. links to canonical file-format or wire-protocol contracts.
### docs/internal/ ### docs/internal/
**Audience:** developers, LLM coding agents **Audience:** developers, LLM coding agents
@@ -289,7 +328,7 @@ Roadmap docs should not be confused with current behavior.
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain. Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses. This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses or exposes.
Use one file per integration where useful. Use one file per integration where useful.
@@ -348,6 +387,7 @@ Before merging documentation changes, verify:
- `docs/policy/architecture.md` describes development principles. - `docs/policy/architecture.md` describes development principles.
- Future work appears only under `docs/roadmap/`. - Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals. - User-facing docs avoid unnecessary internals.
- Consumer-facing docs explain public APIs without duplicating integration contracts.
- Developer-facing docs preserve boundaries and invariants. - Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema. - Config examples match the schema.
- CLI examples match real commands and flags. - CLI examples match real commands and flags.

25
docs/roadmap/api.md Normal file
View File

@@ -0,0 +1,25 @@
# API Roadmap
This document records API work that is not part of the current implementation. Current HTTP upload behavior is documented in `docs/integrations/http-upload.md`, current configuration behavior is documented in `docs/config.md`, and current producer package usage is documented under `docs/consumers/`.
## Deferred Upload API Work
- Durable upload status storage.
- Durable idempotency records across server restarts.
- Run listing, retry, and cancellation endpoints.
- Long-polling or wait-for-completion workflows.
- Multipart, resumable, or streaming upload protocols.
- Additional archive content negotiation beyond tar and gzip-compressed tar.
- URL-token authentication for constrained clients.
- Upload token lifecycle tooling.
- Mutual TLS or other in-app identity mechanisms.
- In-app TLS termination.
- In-app public exposure policy.
- In-app upload rate limiting.
## Boundaries
- Producers do not choose destination ids, destination paths, transforms, links, publish policy, transfer policy, or storage backends through upload requests.
- Source manifests remain free of routing, destination, transform, and credential data.
- `http_upload` remains source-only unless a future design changes that contract.
- Public access policy, TLS termination, and rate limiting belong in deployment infrastructure unless a future design changes that boundary.

80
docs/roadmap/future.md Normal file
View File

@@ -0,0 +1,80 @@
# Future Roadmap
This document records planned or deferred work that is not part of the current
implementation. Current behavior is documented outside roadmap files in the
README, integration contracts, operations guide, troubleshooting guide, and
internal docs.
## Durability And Recovery
- Durable upload status persistence across process restarts.
- Durable idempotency records across server restarts.
- Database-backed upload queueing.
- Recovery semantics for queued or running uploads after a restart.
- Durable producer retry processing.
## Producer Client Workflows
- Durable client queues or background producer workers.
- `UploadAndWait` helper.
- Long-polling helper or equivalent wait workflow.
## Run Control APIs
- Run retry endpoints.
- Run cancellation endpoints.
- Run listing endpoints.
## Archive And Transport Protocols
- Zstandard-compressed tar archives.
- Additional content negotiation rules for future archive formats.
- Multipart upload support.
- Resumable upload support.
- Streaming upload protocols.
## Destination Backends
- GitHub Gist destination backend support.
- Authentication and secret handling for GitHub API tokens.
- Gist file mapping, update, replacement, and conflict semantics.
- Rate-limit handling and retry behavior for GitHub API responses.
## Docker Image Support
- Official container image build and release workflow.
- Runtime filesystem layout for config, secrets, staging, and local outputs.
- Container-oriented examples for `run` and `serve`.
- Image tagging, versioning, and upgrade guidance.
## Notifications And Hooks
- Email notification support for completed, failed, or partially failed
distribution runs.
- SMTP configuration, authentication, secret handling, and recipient policy.
- ntfy notification support for completed, failed, or partially failed
distribution runs.
- ntfy topic, server, token, priority, and action configuration.
- General post-distribution hook support.
- Hook payload contract that can pass run status, summaries, destination
outcomes, output metadata, and public links to external tools.
- Local executable hook adapter with bounded arguments, environment, stdin,
timeout, exit-code handling, and secret-redaction behavior.
## Authentication And Deployment Surface
- URL-token authentication for constrained clients.
- Additional token lifecycle tooling.
- Mutual TLS or other in-app identity mechanisms.
- In-app TLS.
- Public exposure defaults.
- Browser UI.
## Boundaries
- `http_upload` remains source-only unless a future implementation changes that
contract.
- Current upload status, queue, and idempotency state are memory-only.
- Producers submit complete tar or gzip-compressed tar source bundles today.
- Public access policy, TLS termination, and rate limiting belong outside
`distributor` unless a future implementation changes that boundary.

View File

@@ -1,102 +1,22 @@
# Roadmap # Upload API Implementation Notes
This directory contains only future, deferred, or aspirational work for This file has no active implementation tasks for the pipeline-scoped upload API.
`distributor`. Implemented behavior is documented in the current user,
operator, internal, policy, integration, and example documentation: Current behavior is documented in:
- `README.md`
- `docs/cli.md`
- `docs/config.md` - `docs/config.md`
- `docs/integrations/http-upload.md`
- `docs/consumers/api.md`
- `docs/consumers/pkg-upload.md`
- `docs/operations.md` - `docs/operations.md`
- `docs/troubleshooting.md` - `docs/troubleshooting.md`
- `docs/internal/`
- `docs/integrations/markdown.md`
- `docs/policy/`
- `examples/`
`distributor` currently supports local, SSH/SFTP, and S3-compatible source and Deferred API work is tracked in `docs/roadmap/api.md` and broader deferred work is tracked in `docs/roadmap/future.md`.
destination backends; producer bundle creation through `pkg/bundle` and
`distributor manifest create`; configured source validation and inspection;
Markdown sidecar and `index.html` publication; archive and fixed destination
path mapping; destination link metadata; shared text/JSON CLI output; and
managed destination replacement behavior.
## Future Work ## Boundaries
These items are not implemented. They should not be documented as current - Keep producer routing, destination selection, transform policy, publish policy, transfer policy, and backend credentials out of source manifests.
behavior outside `docs/roadmap/` unless a future implementation adds them. - Keep `pkg/upload` focused on producer-side bundle submission and status polling.
- Keep server-side upload authentication, authorization, queueing, status, and publish orchestration in `internal/app`.
### CLI And Status Output - Keep archive extraction and staged bundle validation in `internal/ingest`.
- Keep durable status, durable idempotency, retry endpoints, cancellation endpoints, and wait helpers out of the current implementation until a new roadmap item defines them.
- Add a root-global output flag only if the command parser is later refactored
around shared root options.
- Add output formats beyond `text` and `json` only if a concrete consumer
requires them.
- Add a versioned JSON schema reference after the first JSON-capable release.
- Add destination-state inspection behind an explicit flag such as
`--with-destinations` if operators need fan-out status diagnostics from
`inspect`.
- Add additional status or inspection presentation for destination primary
links beyond the current `run --format json` result model.
### Producer Workflows
- Add a no-write manifest creation mode, such as writing manifest JSON to
stdout, if producer pipelines need to capture manifests directly.
- Add broader producer workflow helpers, such as richer ignore rules or
template scaffolding, if real producer use cases require them.
- Add remote or storage-backed producer writers only if producer applications
need to assemble bundles outside the local filesystem.
### Publication And Transform Behavior
- Add a separate collection or site-index transform if distributor needs
multi-page aggregation.
- Add richer transform metadata only if future state consumers need more than
the transform name and output path.
- Add custom HTML index output names only if fixed `index.html` is too limiting
for real deployments.
- Add richer fixed-destination source selection policies if deployments need
something other than newest-by-`created`.
- Add stricter handling for equal latest timestamps if timestamp ties become
common in producer workflows.
- Add higher-level status or approval workflows for fixed-root replacements if
dry-run output is not enough operational protection.
- Add richer link policies only if `auto`, `html`, and `source` prove
insufficient.
### State And Compatibility
- Define a post-release destination state schema bump policy before introducing
materially incompatible state changes.
- Add warning-only digest mismatch handling only if an operator workflow needs
publication to continue after validation failures.
- Add compatibility parsing for legacy SSH URI config only if migration support
is required.
### Backends, Security, And Deployment
- Add authentication mechanisms beyond the implemented SSH agent/key and S3
credential paths only when a concrete backend workflow requires them.
- Add broad recursive destination deletion outside managed bundle paths only if
a future design can preserve the current safety boundary.
- Add concurrent fan-out publishing only if runtime profiling shows it is
needed.
- Add streaming, resumable, or multipart S3 uploads only if object sizes make
the current write path insufficient.
- Add cloud-provider-specific IAM integration docs only when the repository
includes tested provider-specific behavior.
- Add repository-managed packaging, release, and deployment automation when the
release process is ready to be standardized.
## Roadmap Maintenance
When adding future roadmap work:
- describe user-visible behavior and safety boundaries;
- define which current docs must change after implementation;
- keep examples secret-free and runnable or clearly environment-gated;
- keep workflow labels out of production code, tests, config fields, and
user-facing documentation;
- run focused tests for the changed behavior and `go test ./...` for
cross-package changes.

View File

@@ -1,8 +1,14 @@
# Distributor Troubleshooting # Distributor Troubleshooting
## `load config ... no such file or directory` Audience: administrators and operators diagnosing `distributor` command, configuration, publishing, storage, or HTTP upload failures.
Likely cause: `run` could not find the config path. If `--config` is omitted, the default path is `/usr/local/etc/distributor/config.yml`. Each entry lists the symptom, likely cause, diagnostic step, safe fix, and relevant reference link. Command syntax lives in [CLI](cli.md), configuration fields live in [Configuration](config.md), and operating procedures live in [Operations](operations.md).
## Config File Is Missing
Symptom: `load config ... no such file or directory`.
Likely cause: `--config` points to a missing file, or `--config` was omitted and `/usr/local/etc/distributor/config.yml` is not installed.
Diagnostic: Diagnostic:
@@ -10,11 +16,15 @@ Diagnostic:
ls -l <config-path> 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). Safe fix: pass an existing file with `--config`, or install a config at the default path.
## `parse config ... field not found` Reference: [Configuration](config.md#config-file-loading).
Likely cause: the YAML contains an unknown field. Config loading rejects unknown keys. ## Config Contains An Unknown Field
Symptom: `parse config ... field not found`.
Likely cause: the YAML contains a key that is not part of the implemented config schema.
Diagnostic: Diagnostic:
@@ -22,11 +32,15 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --dry-run 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. Safe fix: remove or rename unsupported fields using the canonical config reference.
## `validate config ... backend ... is unsupported` Reference: [Configuration](config.md).
Likely cause: a source or destination uses a backend name other than `local`, `ssh`, or `s3`. ## Backend Name Or Placement Is Invalid
Symptom: `backend ... is unsupported` or `http_upload is only supported for sources`.
Likely cause: a backend name is misspelled, not executable, or configured in the wrong role.
Diagnostic: Diagnostic:
@@ -34,35 +48,15 @@ Diagnostic:
rg -n "backend:" <config-path> rg -n "backend:" <config-path>
``` ```
Safe fix: use `backend: local`, `backend: ssh`, or `backend: s3` for executable workflows. Safe fix: use `local`, `ssh`, or `s3` for executable sources and destinations. Use `http_upload` only as a source served by `distributor serve`.
## `--format: format must be text or json` Reference: [Configuration](config.md#backend-reference).
Likely cause: a command was run with an unsupported output format. ## CLI Arguments Select The Wrong Source Mode
Diagnostic: Symptom: `configured source mode requires --pipeline`, `does not accept a local path with --config, --pipeline, or --bundle`, `validate command requires a path`, or `inspect command requires a path`.
```sh Likely cause: `validate` or `inspect` mixed local path mode with configured source mode, or omitted the required source selector.
go run ./cmd/distributor run --help
```
Safe fix: use `--format text` or `--format json`. Help and usage output are always text.
## `--format json` wrote no JSON output
Likely cause: the command failed before it could construct a result, such as a missing config file, invalid arguments, unreadable secrets directory, or source setup failure.
Diagnostic:
```sh
go run ./cmd/distributor run --config <config-path> --format json
```
Safe fix: read the stderr error and fix the setup problem. JSON mode writes a document only after the command has enough information to construct a result.
## `configured source mode requires --pipeline`
Likely cause: `validate` or `inspect` was run with `--config` but without an explicit pipeline id.
Diagnostic: Diagnostic:
@@ -71,23 +65,31 @@ go run ./cmd/distributor validate --help
go run ./cmd/distributor inspect --help go run ./cmd/distributor inspect --help
``` ```
Safe fix: add `--pipeline <pipeline-id>`. Configured source diagnostics require an explicit pipeline even when the config contains one pipeline. Safe fix: use either `distributor validate <path>` / `distributor inspect <path>`, or use `--config <path> --pipeline <id>` with optional `--bundle <path>`.
## `does not accept a local path with --config, --pipeline, or --bundle` Reference: [CLI](cli.md#validate).
Likely cause: local-path mode and configured source mode were mixed in one `validate` or `inspect` command. ## Output Format Is Invalid
Symptom: `format must be text or json`.
Likely cause: an unsupported value was passed to `--format`.
Diagnostic: Diagnostic:
```sh ```sh
go run ./cmd/distributor inspect --help go run ./cmd/distributor run --help
``` ```
Safe fix: use either `distributor inspect <local-path>` or `distributor inspect --config <path> --pipeline <id>`, not both. Safe fix: use `--format text` or `--format json`.
## `--format json` exited non-zero with `ok: false` Reference: [CLI](cli.md#common-output-format).
Likely cause: `run` began planning or executing destinations, and at least one destination failed while other destination results were still available. ## JSON Mode Wrote No JSON Document
Symptom: `--format json` exits non-zero and stdout has no JSON result.
Likely cause: the command failed before it could construct a result, such as invalid arguments, missing config, unreadable secrets, or source setup failure.
Diagnostic: Diagnostic:
@@ -95,11 +97,261 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --format json go run ./cmd/distributor run --config <config-path> --format json
``` ```
Safe fix: inspect the top-level `errors` array, `result.actions`, and `result.summary`. Fix the failed destination, then preview with `--dry-run --format json` before retrying. Safe fix: read stderr, fix the setup problem, then rerun. Partial destination failures during `run` can produce JSON; fatal setup failures do not.
## `prefix must be a clean relative slash-separated path` Reference: [CLI](cli.md#output-and-exit-behavior).
Likely cause: S3 `prefix` contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed. ## Source Pipeline Is Not Found
Symptom: `pipeline "<id>" not found`.
Likely cause: configured source diagnostics or upload processing selected a pipeline id that is absent from the loaded config.
Diagnostic:
```sh
rg -n "id:" <config-path>
```
Safe fix: pass an existing `--pipeline` value or correct the pipeline id in config.
Reference: [Configuration](config.md#pipelines).
## Source Bundles Are Not Found
Symptom: `no bundles found`, `no bundles found under "."`, `stat ssh ... not_found`, or `stat s3 ... not_found`.
Likely cause: the source root, source-root-relative bundle path, S3 prefix, SSH path, or permissions do not expose a directory containing `manifest.json`.
Diagnostic:
```sh
go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
```
Safe fix: correct the configured source backend root, permissions, prefix, or `--bundle` path. The selected bundle directory must contain `manifest.json`.
Reference: [Operations](operations.md#filesystem-and-storage-layout).
## Source Manifest Or Files Fail Validation
Symptom: `sha256 mismatch`, `size mismatch`, `digest mismatch`, missing manifest fields, or unsafe source paths.
Likely cause: files changed after `manifest.json` was written, the manifest digest is stale, or the producer wrote invalid bundle paths.
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 validation failures.
Reference: [Operations](operations.md#cleanup-and-recovery).
## Destination Has Unmanaged Content
Symptom: `destination has content but no distributor state` or a plan reason containing `fail_unmanaged`.
Likely cause: the destination bundle path contains files but no valid `.distributor.json`, so `distributor` will not claim it by default.
Diagnostic:
```sh
find <destination-path> -maxdepth 2 -print
```
Safe fix: choose an empty destination path, move unrelated files aside, or preview `run --dry-run --force` only after confirming the reported bundle path is safe to replace.
Reference: [Operations](operations.md#forced-replacement-workflow).
## Destination State Conflicts With Source
Symptom: `fail_conflict`, `destination source id differs`, `same id and created time but different digest`, `pipeline id ... does not match`, or `destination id ... does not match`.
Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, or same-created source with different content.
Diagnostic:
```sh
cat <destination-path>/.distributor.json
go run ./cmd/distributor inspect <source-root>
```
Safe fix: verify the source and destination are intended to match. Use a separate destination path for unrelated content. To replace the existing state, configure `transfer.on_conflict: replace`, preview with `--dry-run --force`, then publish with `--force`.
Reference: [Operations](operations.md#destination-state-and-retry-behavior).
## Destination Is Newer Than Source
Symptom: `skip_destination_newer` or `destination is newer and replacement requires --force`.
Likely cause: the destination state records a source manifest with a later `created` timestamp than the current source.
Diagnostic:
```sh
go run ./cmd/distributor run --config <config-path> --dry-run --format json
```
Safe fix: keep the default skip behavior unless replacement is intentional. To replace newer state, configure `transfer.on_destination_newer: replace`, preview with `--dry-run --force`, then publish with `--force`.
Reference: [Operations](operations.md#forced-replacement-workflow).
## Forced Replacement Appears In A Plan
Symptom: dry-run output includes `force_replace`.
Likely cause: the run used `--force`, and planning selected a supported destructive replacement.
Diagnostic:
```sh
go run ./cmd/distributor run --config <config-path> --dry-run --force
```
Safe fix: inspect the pipeline id, destination id, backend, and bundle path. Proceed only if deleting everything inside that destination bundle path is intended.
Reference: [Operations](operations.md#forced-replacement-workflow).
## Output Path Collision
Symptom: `destination output path collision`.
Likely cause: publication would write two outputs to the same destination path, such as copying `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 source files or publish/transform policy so copied and generated outputs do not collide.
Reference: [Configuration](config.md#publish-and-transform-policy).
## Run Failed After Writing Some Files
Symptom: a destination write failed and the command exited non-zero after partial work.
Likely cause: storage write failure, permission issue, network interruption, or object-store error during publish execution.
Diagnostic:
```sh
find <destination-path> -maxdepth 2 -print
```
Safe fix: inspect the destination bundle path printed in the error. `distributor` attempts to remove outputs from the failed attempt, but operators should verify the destination before retrying. Rerun `--dry-run` before publishing again.
Reference: [Operations](operations.md#destination-state-and-retry-behavior).
## JSON Run Result Has `ok: false`
Symptom: `run --format json` exits non-zero with a JSON result where `ok` is `false`.
Likely cause: at least one destination failed after planning or execution began, while other destination results were still available.
Diagnostic:
```sh
go run ./cmd/distributor run --config <config-path> --format json
```
Safe fix: inspect the top-level `errors` array, destination actions, output errors, and summary. Fix failed destinations, then preview with `--dry-run --format json`.
Reference: [CLI](cli.md#output-and-exit-behavior).
## Secrets Directory Is Missing Or Unreadable
Symptom: `load secrets directory ... no such file or directory`, `permission denied`, or `secret filename ... is invalid`.
Likely cause: `secrets.directory` points to a missing or unreadable directory, or it contains a regular file whose name is not a valid credential variable name.
Diagnostic:
```sh
ls -ld <secrets-directory>
find <secrets-directory> -maxdepth 1 -type f -printf '%f\n'
```
Safe fix: mount or create the directory, adjust permissions for the service user, or rename/remove invalid secret files. Secret filenames must match `[A-Za-z_][A-Za-z0-9_]*`.
Reference: [Configuration](config.md#secrets).
## Credential Variable Is Missing Or Empty
Symptom: `credential environment variable ... is not set`, `credential environment variable ... is empty`, or S3 authentication errors such as `AccessDenied`, `InvalidAccessKeyId`, or `SignatureDoesNotMatch`.
Likely cause: configured S3 credential variable names are not available through the process environment or `secrets.directory`, are empty, or do not authorize the requested bucket/prefix.
Diagnostic:
```sh
env | cut -d= -f1 | rg '^(<access-key-variable>|<secret-key-variable>)$'
ls -l <secrets-directory>
```
Safe fix: provide both configured S3 credential values, correct IAM/service permissions, or omit explicit credential fields to use the AWS SDK default credential chain.
Reference: [Configuration](config.md#s3-compatible-backend).
## Secret File Is Ignored In Favor Of Environment
Symptom: `secret ... ignored because the real environment already has that variable`.
Likely cause: the same credential name exists in the process environment and `secrets.directory` 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 intentionally keep the process environment value. `distributor` does not print either value.
Reference: [Operations](operations.md#secrets-operation).
## SSH Auth Is Not Configured
Symptom: `no SSH auth methods configured`.
Likely cause: no SSH agent is available and `ssh_key_file` is missing or unreadable.
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 a readable private key with `ssh_key_file`.
Reference: [Configuration](config.md#sshsftp-backend).
## SSH Host Key Fails
Symptom: `host key ... is unknown`, `known_hosts is required`, or `host key ... has changed`.
Likely cause: strict host key checking has no trusted key, `accept-new` cannot persist a new key, or the remote host key differs from the stored key.
Diagnostic:
```sh
ls -l <known-hosts-path>
ssh-keygen -F <host> -f <known-hosts-path>
```
Safe fix: pre-populate `known_hosts` for `strict`, configure a writable `known_hosts` path for `accept-new`, or verify the server identity before updating a changed key. Do not disable host key checking to bypass an unexpected changed key.
Reference: [Operations](operations.md#sshsftp).
## S3 Prefix Is Invalid
Symptom: `prefix must be a clean relative slash-separated path`.
Likely cause: the S3 prefix contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed.
Diagnostic: Diagnostic:
@@ -109,286 +361,145 @@ go run ./cmd/distributor run --config <config-path> --dry-run
Safe fix: use a clean relative prefix such as `reports/archive`, or omit `prefix`. Safe fix: use a clean relative prefix such as `reports/archive`, or omit `prefix`.
## `NoSuchBucket`, `InvalidBucketName`, or `not_found` Reference: [Configuration](config.md#s3-compatible-backend).
Likely cause: the S3 bucket, endpoint, or prefix is wrong, or the configured credentials cannot see the requested object. ## S3 Location Or Connectivity Fails
Symptom: `NoSuchBucket`, `InvalidBucketName`, `not_found`, endpoint connection failures, or TLS/network errors.
Likely cause: endpoint, bucket, prefix, region, path-style mode, network routing, or credentials are wrong for the service.
Diagnostic: Diagnostic:
```sh ```sh
go run ./cmd/distributor run --config <config-path> --dry-run 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> 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. Safe fix: verify `endpoint`, `bucket`, `region`, `prefix`, and `force_path_style`. For S3-compatible services, keep `force_path_style: true` unless the service requires virtual-host addressing. Distributor does not provide an insecure TLS bypass setting.
## `load secrets directory ... no such file or directory` Reference: [Operations](operations.md#s3-compatible-storage).
Likely cause: `secrets.directory` points to a missing directory. ## HTTP Server Cannot Bind
Symptom: `bind HTTP server ... address already in use`.
Likely cause: another process is listening on `server.http.bind`.
Diagnostic: Diagnostic:
```sh ```sh
ls -ld <secrets-directory> ss -ltnp | rg '<port>'
``` ```
Safe fix: create or mount the directory before running, or remove `secrets.directory` if no credential files are needed. Safe fix: stop the conflicting process or configure a different bind address.
## `load secrets directory ... permission denied` Reference: [Configuration](config.md#serverhttp).
Likely cause: the service user cannot read the configured secrets directory. ## HTTP Upload Token Is Missing Or Duplicated
Symptom: `upload token environment variable ... is not set`, `... is empty`, or `upload token environment variables ... resolve to the same value`.
Likely cause: a top-level upload token record references a missing or empty `token_env`, or two token records resolve to the same bearer token.
Diagnostic: Diagnostic:
```sh ```sh
ls -ld <secrets-directory> rg -n 'token_env:' <config-path>
namei -l <secrets-directory> env | cut -d= -f1 | rg '^<token-variable>$'
ls -l <secrets-directory>/<token-variable>
``` ```
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. Safe fix: provide one distinct non-empty token value per upload token record through the process environment or `secrets.directory`. Do not put literal tokens in YAML.
## `secret filename ... is invalid` Reference: [Configuration](config.md#upload_tokens).
Likely cause: a regular file in `secrets.directory` does not match `[A-Za-z_][A-Za-z0-9_]*`. ## Upload Request Is Unauthorized
Symptom: `POST /v1/pipelines/<pipeline-id>/upload` returns `401`.
Likely cause: the request lacks `Authorization: Bearer <token>`, has an empty token, or uses a token that does not match any configured upload token record.
Diagnostic: Diagnostic:
```sh ```sh
find <secrets-directory> -maxdepth 1 -type f -printf '%f\n' curl -i -X POST http://127.0.0.1:8080/v1/pipelines/<pipeline-id>/upload \
-H "Authorization: Bearer $DISTRIBUTOR_UPLOAD_TOKEN" \
-H "Content-Type: application/x-tar" \
--data-binary @bundle.tar
``` ```
Safe fix: rename the file to a valid credential environment variable name, or remove it from the secrets directory. Safe fix: use the token value resolved by the configured `token_env`. Do not include token values in logs or tickets.
## `credential environment variable ... is not set` Reference: [Operations](operations.md#http-upload-operation).
Likely cause: a backend credential field references an environment variable that is absent from both the real process environment and the configured secrets directory. ## Upload Request Is Forbidden
Symptom: `POST /v1/pipelines/<pipeline-id>/upload` returns `403`.
Likely cause: the bearer token is valid, but its configured `allow_pipelines` list does not include the requested upload pipeline.
Diagnostic: Diagnostic:
```sh ```sh
env | cut -d= -f1 | rg '^<variable-name>$' rg -n 'upload_tokens:|allow_pipelines:|id:' <config-path>
ls -l <secrets-directory>/<variable-name>
``` ```
Safe fix: set the real environment variable or create a readable secrets-directory file with the same name. Safe fix: request the intended pipeline id, or update the token allowlist to include the configured `http_upload` pipeline that this producer may submit to.
## `secret ... ignored because the real environment already has that variable` Reference: [Configuration](config.md#upload_tokens).
Likely cause: the real process environment and secrets directory both define the variable with different values. ## Upload Request Is Rejected Before A Run ID
Symptom: `POST /v1/pipelines/<pipeline-id>/upload` returns `400`, `413`, `415`, or `503`.
Likely cause: the request path has an invalid pipeline id, included a `pipeline` or `pipeline_id` query, archive content is malformed, the body exceeds size limits, content type is unsupported, or the in-memory upload queue is full.
Diagnostic: Diagnostic:
```sh ```sh
env | cut -d= -f1 | rg '^<variable-name>$' tar -tf bundle.tar
ls -l <secrets-directory>/<variable-name> tar -tzf bundle.tar.gz
rg -n 'max_upload_size|queue_size|max_concurrency' <config-path>
``` ```
Safe fix: remove one source of the credential or make the deployment intentionally prefer the real environment value. Distributor does not print either value. Safe fix: send one valid tar or tar.gz source bundle archive to `/v1/pipelines/<pipeline-id>/upload` with `Content-Type: application/x-tar`, `application/gzip`, or `application/x-gzip`; remove pipeline query parameters; reduce archive size or raise the configured limit; retry after queue pressure drops.
## `host is required for ssh backend` Reference: [Operations](operations.md#http-upload-operation).
Likely cause: SSH config is missing the structured `host` field, or an old URL-style SSH config is still in use. ## Upload Idempotency Conflict
Symptom: `POST /v1/pipelines/<pipeline-id>/upload` returns `409`.
Likely cause: the request reused an `Idempotency-Key` for the same token id and pipeline id with a different source manifest, or another request with the same key is still being staged before its manifest is known.
Diagnostic: Diagnostic:
```sh ```sh
go run ./cmd/distributor run --config <config-path> --dry-run curl -i -X POST http://127.0.0.1:8080/v1/pipelines/<pipeline-id>/upload \
-H "Authorization: Bearer $DISTRIBUTOR_UPLOAD_TOKEN" \
-H "Content-Type: application/gzip" \
-H "Idempotency-Key: <key>" \
--data-binary @bundle.tar.gz
``` ```
Safe fix: configure SSH with `host`, optional `user` and `port`, and `path`. SSH URLs are not part of the active config schema. Safe fix: if the response includes `"retryable":true`, retry the same upload later with the same key. Otherwise, inspect the producer operation and use the same key only for the same source bundle.
## `no SSH auth methods configured` Reference: [HTTP Upload API Contract](integrations/http-upload.md).
Likely cause: neither an SSH agent nor `ssh_key_file` is available. ## Upload Status Is Missing
Symptom: `GET /runs/<run_id>` returns `404`.
Likely cause: the run id is wrong, the process restarted, or the retained status record expired after `server.http.retention`.
Diagnostic: Diagnostic:
```sh ```sh
test -n "$SSH_AUTH_SOCK" && ssh-add -l curl -i http://127.0.0.1:8080/runs/<run-id>
ls -l <ssh-key-file> rg -n 'retention:' <config-path>
``` ```
Safe fix: start an SSH agent with an appropriate key loaded, or configure `ssh_key_file` with a readable private key. Safe fix: use the exact `run_id` returned by upload admission. Increase retention if operators need a longer status window.
## `host key ... is unknown` or `known_hosts is required` Reference: [Operations](operations.md#http-upload-operation).
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.
## `pipeline "<id>" not found`
Likely cause: configured source validation or inspection requested a pipeline id that is not present in the config file.
Diagnostic:
```sh
rg -n "id:" <config-path>
```
Safe fix: pass an existing pipeline id with `--pipeline`, or update the config.
## `stat ssh ... not_found`, `stat s3 ... not_found`, or `no bundles found`
Likely cause: the configured source root is wrong, unreadable, or does not contain source bundles.
Diagnostic:
```sh
go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
```
Safe fix: correct the configured source root, S3 prefix, permissions, or source bundle location. Use `--bundle <path>` only with a source-root-relative bundle directory that contains `manifest.json`.
## `validate command requires a path` or `inspect command requires a path`
Likely cause: `validate` or `inspect` was run without a local path and without configured source mode.
Diagnostic:
```sh
go run ./cmd/distributor validate --help
go run ./cmd/distributor inspect --help
```
Safe fix: pass a local source bundle directory or local tree, or pass both `--config <path>` and `--pipeline <id>`.
## `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).

View File

@@ -0,0 +1,27 @@
# Local HTTP upload example.
# Set DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN in the process environment or provide a
# secrets-directory file with that name before running `distributor serve`.
server:
http:
bind: 127.0.0.1:8080
staging_root: workspace/http-upload/staging
max_upload_size: 20MB
queue_size: 16
max_concurrency: 1
retention: 24h
upload_tokens:
- id: example-uploader
token_env: DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN
allow_pipelines:
- example-http-upload
pipelines:
- id: example-http-upload
source:
backend: http_upload
destinations:
- id: local-archive
backend: local
path: workspace/published/http-upload
publish:
source: true
html: false

View File

@@ -0,0 +1,57 @@
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
)
func main() {
token := os.Getenv("DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN")
if token == "" {
log.Fatal("set DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN before running this example")
}
endpoint := os.Getenv("DISTRIBUTOR_EXAMPLE_UPLOAD_ENDPOINT")
if endpoint == "" {
endpoint = "http://127.0.0.1:8080"
}
bundleRoot := "examples/source-bundle"
if len(os.Args) > 1 {
bundleRoot = os.Args[1]
}
pipelineID := os.Getenv("DISTRIBUTOR_EXAMPLE_UPLOAD_PIPELINE_ID")
if pipelineID == "" {
pipelineID = "example-http-upload"
}
if len(os.Args) > 2 {
pipelineID = os.Args[2]
}
idempotencyKey := os.Getenv("DISTRIBUTOR_EXAMPLE_UPLOAD_IDEMPOTENCY_KEY")
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: endpoint,
Token: token,
})
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
opts := upload.UploadBundleOptions{
PipelineID: pipelineID,
Root: bundleRoot,
}
if idempotencyKey != "" {
opts.IdempotencyKey = idempotencyKey
}
result, err := client.UploadBundle(ctx, opts)
if err != nil {
log.Fatal(err)
}
fmt.Printf("accepted run %s with status %s\n", result.RunID, result.Status)
}

View File

@@ -65,55 +65,19 @@ func writeInspectResult(options InspectOptions, selection sourceSelection) error
} }
type inspectResult struct { type inspectResult struct {
PipelineID string `json:"pipeline_id,omitempty"` PipelineID string `json:"pipeline_id,omitempty"`
SourceBackend string `json:"source_backend,omitempty"` SourceBackend string `json:"source_backend,omitempty"`
BundleCount int `json:"bundle_count"` BundleCount int `json:"bundle_count"`
Bundles []inspectBundleResult `json:"bundles"` Bundles []bundleDetailResult `json:"bundles"`
}
type inspectBundleResult struct {
Path string `json:"path"`
ID string `json:"id"`
Created string `json:"created"`
Digest string `json:"digest"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
Files []inspectFileResult `json:"files"`
}
type inspectFileResult struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
} }
func inspectResultFromSelection(selection sourceSelection) inspectResult { func inspectResultFromSelection(selection sourceSelection) inspectResult {
result := inspectResult{ return inspectResult{
PipelineID: selection.PipelineID, PipelineID: selection.PipelineID,
SourceBackend: selection.SourceBackend, SourceBackend: selection.SourceBackend,
BundleCount: len(selection.Bundles), BundleCount: len(selection.Bundles),
Bundles: make([]inspectBundleResult, 0, len(selection.Bundles)), Bundles: bundleDetailsFromBundles(selection.Bundles),
} }
for _, sourceBundle := range selection.Bundles {
bundleResult := inspectBundleResult{
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
ID: sourceBundle.Manifest.ID,
Created: sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"),
Digest: sourceBundle.Manifest.Digest,
FileCount: len(sourceBundle.Manifest.Files),
Files: make([]inspectFileResult, 0, len(sourceBundle.Manifest.Files)),
}
for _, file := range sourceBundle.Manifest.Files {
bundleResult.TotalSize += file.Size
bundleResult.Files = append(bundleResult.Files, inspectFileResult{
Path: file.Path,
SHA256: file.SHA256,
Size: file.Size,
})
}
result.Bundles = append(result.Bundles, bundleResult)
}
return result
} }
func writeInspection(w io.Writer, selection sourceSelection) error { func writeInspection(w io.Writer, selection sourceSelection) error {
@@ -134,7 +98,7 @@ func writeInspection(w io.Writer, selection sourceSelection) error {
"- path=%s id=%s created=%s digest=%s files=%d\n", "- path=%s id=%s created=%s digest=%s files=%d\n",
storage.DisplayPath(sourceBundle.RootRelativePath), storage.DisplayPath(sourceBundle.RootRelativePath),
sourceBundle.Manifest.ID, sourceBundle.Manifest.ID,
sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"), formatManifestCreated(sourceBundle.Manifest.Created),
sourceBundle.Manifest.Digest, sourceBundle.Manifest.Digest,
len(sourceBundle.Manifest.Files), len(sourceBundle.Manifest.Files),
); err != nil { ); err != nil {

View File

@@ -3,9 +3,12 @@ package app
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/testutil" "gitea.maximumdirect.net/eric/distributor/internal/testutil"
) )
@@ -85,6 +88,98 @@ func TestInspectConfiguredSourceJSON(t *testing.T) {
} }
} }
func TestInspectJSONPreservesCreatedOffsetAndFileMetadata(t *testing.T) {
sourceRoot := t.TempDir()
created := time.Date(2026, 6, 1, 6, 30, 0, 0, time.FixedZone("CDT", -5*60*60))
testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{
ID: "reports.offset",
Created: created,
Files: []testutil.SourceFile{
{Path: "report.md", Data: "# Report\n"},
},
})
var stdout bytes.Buffer
err := Inspect(context.Background(), InspectOptions{
Path: sourceRoot,
Stdout: &stdout,
OutputFormat: OutputFormatJSON,
})
if err != nil {
t.Fatalf("Inspect() error = %v", err)
}
result := decodeAppResult(t, stdout.String())
bundles, ok := result["bundles"].([]any)
if !ok || len(bundles) != 1 {
t.Fatalf("bundles = %#v, want one bundle", result["bundles"])
}
bundle, ok := bundles[0].(map[string]any)
if !ok {
t.Fatalf("bundle = %#v, want object", bundles[0])
}
if bundle["created"] != "2026-06-01T06:30:00-05:00" || bundle["file_count"] != float64(1) {
t.Fatalf("bundle = %#v, want offset timestamp and file count", bundle)
}
files, ok := bundle["files"].([]any)
if !ok || len(files) != 1 {
t.Fatalf("files = %#v, want one file", bundle["files"])
}
file, ok := files[0].(map[string]any)
if !ok || file["path"] != "report.md" || file["sha256"] == "" || file["size"] != float64(9) {
t.Fatalf("file = %#v, want projected file metadata", file)
}
}
func TestInspectConfiguredSourceJSONIncludesSecretConflictWarningWithoutValues(t *testing.T) {
name := "DISTRIBUTOR_TEST_INSPECT_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)
}
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{ID: "reports.json"})
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 := Inspect(context.Background(), InspectOptions{
ConfigPath: configPath,
PipelineID: "reports",
Stdout: &stdout,
OutputFormat: OutputFormatJSON,
})
if err != nil {
t.Fatalf("Inspect() error = %v", err)
}
var envelope struct {
Warnings []OutputWarning `json:"warnings"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v; output = %q", err, stdout.String())
}
if len(envelope.Warnings) != 1 || !strings.Contains(envelope.Warnings[0].Message, "secret "+name+" ignored") {
t.Fatalf("warnings = %#v, want secret conflict warning", envelope.Warnings)
}
output := stdout.String()
if strings.Contains(output, "process-value") || strings.Contains(output, "secret-value") {
t.Fatalf("stdout exposed secret values: %q", output)
}
}
func TestInspectRequiresPath(t *testing.T) { func TestInspectRequiresPath(t *testing.T) {
err := Inspect(context.Background(), InspectOptions{}) err := Inspect(context.Background(), InspectOptions{})
if err == nil || !strings.Contains(err.Error(), "requires a path") { if err == nil || !strings.Contains(err.Error(), "requires a path") {

View File

@@ -92,37 +92,23 @@ func normalizeManifestFiles(files []string) []string {
} }
type manifestCreateResult struct { type manifestCreateResult struct {
ManifestPath string `json:"manifest_path"` ManifestPath string `json:"manifest_path"`
Root string `json:"root"` Root string `json:"root"`
ID string `json:"id"` ID string `json:"id"`
Created string `json:"created"` Created string `json:"created"`
Digest string `json:"digest"` Digest string `json:"digest"`
FileCount int `json:"file_count"` FileCount int `json:"file_count"`
Files []manifestCreateFileResult `json:"files"` Files []manifestFileResult `json:"files"`
}
type manifestCreateFileResult struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
} }
func manifestCreateResultFromManifest(root string, manifest producerbundle.Manifest) manifestCreateResult { func manifestCreateResultFromManifest(root string, manifest producerbundle.Manifest) manifestCreateResult {
result := manifestCreateResult{ return manifestCreateResult{
ManifestPath: filepath.ToSlash(filepath.Join(root, producerbundle.ManifestName)), ManifestPath: filepath.ToSlash(filepath.Join(root, producerbundle.ManifestName)),
Root: filepath.ToSlash(root), Root: filepath.ToSlash(root),
ID: manifest.ID, ID: manifest.ID,
Created: manifest.Created.Format(time.RFC3339), Created: formatManifestCreated(manifest.Created),
Digest: manifest.Digest, Digest: manifest.Digest,
FileCount: len(manifest.Files), FileCount: len(manifest.Files),
Files: make([]manifestCreateFileResult, 0, len(manifest.Files)), Files: manifestFileResults(manifest.Files),
} }
for _, file := range manifest.Files {
result.Files = append(result.Files, manifestCreateFileResult{
Path: file.Path,
SHA256: file.SHA256,
Size: file.Size,
})
}
return result
} }

View File

@@ -0,0 +1,42 @@
package app
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
)
func TestManifestCreateJSONPreservesCreatedOffsetAndFileMetadata(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("# Report\n"), 0o600); err != nil {
t.Fatalf("write report: %v", err)
}
var stdout bytes.Buffer
err := ManifestCreate(context.Background(), ManifestCreateOptions{
Root: root,
ID: "reports.offset",
Created: "2026-06-01T06:30:00-05:00",
Files: []string{"report.md"},
Stdout: &stdout,
OutputFormat: OutputFormatJSON,
})
if err != nil {
t.Fatalf("ManifestCreate() error = %v", err)
}
result := decodeAppResult(t, stdout.String())
if result["id"] != "reports.offset" || result["created"] != "2026-06-01T06:30:00-05:00" || result["file_count"] != float64(1) {
t.Fatalf("result = %#v, want manifest metadata", result)
}
files, ok := result["files"].([]any)
if !ok || len(files) != 1 {
t.Fatalf("files = %#v, want one file", result["files"])
}
file, ok := files[0].(map[string]any)
if !ok || file["path"] != "report.md" || file["sha256"] == "" || file["size"] != float64(9) {
t.Fatalf("file = %#v, want projected file metadata", file)
}
}

View File

@@ -0,0 +1,83 @@
package app
import (
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
type bundleSummaryResult struct {
Path string `json:"path"`
ID string `json:"id"`
}
type bundleDetailResult struct {
Path string `json:"path"`
ID string `json:"id"`
Created string `json:"created"`
Digest string `json:"digest"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
Files []manifestFileResult `json:"files"`
}
type manifestFileResult struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
func bundleSummaryFromBundle(sourceBundle bundle.Bundle) bundleSummaryResult {
return bundleSummaryResult{
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
ID: sourceBundle.Manifest.ID,
}
}
func bundleSummariesFromBundles(sourceBundles []bundle.Bundle) []bundleSummaryResult {
results := make([]bundleSummaryResult, 0, len(sourceBundles))
for _, sourceBundle := range sourceBundles {
results = append(results, bundleSummaryFromBundle(sourceBundle))
}
return results
}
func bundleDetailFromBundle(sourceBundle bundle.Bundle) bundleDetailResult {
result := bundleDetailResult{
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
ID: sourceBundle.Manifest.ID,
Created: formatManifestCreated(sourceBundle.Manifest.Created),
Digest: sourceBundle.Manifest.Digest,
FileCount: len(sourceBundle.Manifest.Files),
Files: manifestFileResults(sourceBundle.Manifest.Files),
}
for _, file := range sourceBundle.Manifest.Files {
result.TotalSize += file.Size
}
return result
}
func bundleDetailsFromBundles(sourceBundles []bundle.Bundle) []bundleDetailResult {
results := make([]bundleDetailResult, 0, len(sourceBundles))
for _, sourceBundle := range sourceBundles {
results = append(results, bundleDetailFromBundle(sourceBundle))
}
return results
}
func manifestFileResults(files []bundle.ManifestFile) []manifestFileResult {
results := make([]manifestFileResult, 0, len(files))
for _, file := range files {
results = append(results, manifestFileResult{
Path: file.Path,
SHA256: file.SHA256,
Size: file.Size,
})
}
return results
}
func formatManifestCreated(created time.Time) string {
return created.Format(time.RFC3339)
}

View File

@@ -8,7 +8,6 @@ import (
"gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/notify" "gitea.maximumdirect.net/eric/distributor/internal/notify"
"gitea.maximumdirect.net/eric/distributor/internal/publish"
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
@@ -21,6 +20,23 @@ type RunOptions struct {
Notifier notify.Notifier Notifier notify.Notifier
} }
type RunPipelineOptions struct {
ConfigPath string
PipelineID string
DryRun bool
Force bool
Notifier notify.Notifier
}
type RunPipelineWithLocalSourceOptions struct {
ConfigPath string
PipelineID string
SourceRoot string
DryRun bool
Force bool
Notifier notify.Notifier
}
func Run(ctx context.Context, options RunOptions) error { func Run(ctx context.Context, options RunOptions) error {
if err := ValidateOutputFormat(options.OutputFormat); err != nil { if err := ValidateOutputFormat(options.OutputFormat); err != nil {
return err return err
@@ -29,221 +45,252 @@ func Run(ctx context.Context, options RunOptions) error {
return err return err
} }
configPath := options.ConfigPath setup, err := loadRuntimeSetup(options.ConfigPath)
if configPath == "" {
configPath = config.DefaultConfigPath
}
cfg, err := config.LoadFile(configPath)
if err != nil { if err != nil {
return err return err
} }
return runConfig(ctx, cfg, options) return runSetup(ctx, setup, options)
}
func RunPipeline(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
if err := ctx.Err(); err != nil {
return RunReport{}, err
}
setup, err := loadRuntimeSetup(options.ConfigPath)
if err != nil {
return RunReport{}, err
}
return runPipelineSetup(ctx, setup, options)
}
func RunPipelineWithLocalSource(ctx context.Context, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
if err := ctx.Err(); err != nil {
return RunReport{}, err
}
if options.SourceRoot == "" {
return RunReport{}, fmt.Errorf("source root is required")
}
setup, err := loadRuntimeSetup(options.ConfigPath)
if err != nil {
return RunReport{}, err
}
return runPipelineSetupWithLocalSource(ctx, setup, options)
} }
func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error { func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error {
return runConfigWithBackendFactory(ctx, cfg, options, newBackendFactoryWithEnvironment) setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return err
}
return runSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
} }
type backendFactoryProvider func(config.Environment) *backendFactory type backendFactoryProvider func(config.Environment) *backendFactory
func runPipelineConfig(ctx context.Context, cfg config.Config, options RunPipelineOptions) (RunReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return RunReport{}, err
}
return runPipelineSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
}
func runPipelineConfigWithLocalSource(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return RunReport{}, err
}
return runPipelineSetupWithLocalSourceAndBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
}
func runPipelineConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunPipelineOptions, provider backendFactoryProvider) (RunReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return RunReport{}, err
}
return runPipelineSetupWithBackendFactory(ctx, setup, options, provider)
}
func runPipelineSetup(ctx context.Context, setup runtimeSetup, options RunPipelineOptions) (RunReport, error) {
return runPipelineSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
}
func runPipelineSetupWithBackendFactory(ctx context.Context, setup runtimeSetup, options RunPipelineOptions, provider backendFactoryProvider) (RunReport, error) {
pipeline, ok := findPipeline(setup.Config, options.PipelineID)
if !ok {
return RunReport{}, PipelineNotFoundError{ID: options.PipelineID}
}
return buildRunReportWithSetup(ctx, setup.withPipelines([]config.Pipeline{pipeline}), RunOptions{
DryRun: options.DryRun,
Force: options.Force,
Notifier: options.Notifier,
}, provider, nil)
}
func runPipelineConfigWithLocalSourceAndBackendFactory(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions, provider backendFactoryProvider) (RunReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return RunReport{}, err
}
return runPipelineSetupWithLocalSourceAndBackendFactory(ctx, setup, options, provider)
}
func runPipelineSetupWithLocalSource(ctx context.Context, setup runtimeSetup, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
return runPipelineSetupWithLocalSourceAndBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
}
func runPipelineSetupWithLocalSourceAndBackendFactory(ctx context.Context, setup runtimeSetup, options RunPipelineWithLocalSourceOptions, provider backendFactoryProvider) (RunReport, error) {
pipeline, ok := findPipeline(setup.Config, options.PipelineID)
if !ok {
return RunReport{}, PipelineNotFoundError{ID: options.PipelineID}
}
return buildRunReportWithSetup(ctx, setup.withPipelines([]config.Pipeline{pipeline}), RunOptions{
DryRun: options.DryRun,
Force: options.Force,
Notifier: options.Notifier,
}, provider, &localSourceRoot{
pipelineID: options.PipelineID,
root: options.SourceRoot,
})
}
func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error { func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return err
}
return runSetupWithBackendFactory(ctx, setup, options, provider)
}
func runSetup(ctx context.Context, setup runtimeSetup, options RunOptions) error {
return runSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
}
func runSetupWithBackendFactory(ctx context.Context, setup runtimeSetup, options RunOptions, provider backendFactoryProvider) error {
report, err := buildRunReportWithSetup(ctx, setup, options, provider, nil)
if err != nil && !IsPartialResultError(err) {
return err
}
if outputErr := WriteRunReport(options.Stdout, options.OutputFormat, report); outputErr != nil {
return outputErr
}
return err
}
func buildRunReportWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) (RunReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return RunReport{}, err
}
return buildRunReportWithSetup(ctx, setup, options, provider, nil)
}
type localSourceRoot struct {
pipelineID string
root string
}
func buildRunReport(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider, sourceRoot *localSourceRoot) (RunReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return RunReport{}, err
}
return buildRunReportWithSetup(ctx, setup, options, provider, sourceRoot)
}
func buildRunReportWithSetup(ctx context.Context, setup runtimeSetup, options RunOptions, provider backendFactoryProvider, sourceRoot *localSourceRoot) (RunReport, error) {
notifier := options.Notifier notifier := options.Notifier
if notifier == nil { if notifier == nil {
notifier = notify.Noop{} notifier = notify.Noop{}
} }
jsonOutput := IsJSONOutput(options.OutputFormat)
summary := runSummary{dryRun: options.DryRun} summary := runSummary{dryRun: options.DryRun}
result := runResult{ report := RunReport{
DryRun: options.DryRun, DryRun: options.DryRun,
Pipelines: []runPipelineResult{}, Pipelines: []RunPipelineSummary{},
Actions: []runActionResult{}, Actions: []RunActionRecord{},
} }
var warnings []OutputWarning
var failures runFailures var failures runFailures
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil) recorder := runReportRecorder{
if err != nil { report: &report,
return err summary: &summary,
failures: &failures,
} }
secretWarnings := secretConflictWarnings(secretLoad.Conflicts) report.PreambleWarnings = append(report.PreambleWarnings, setup.Warnings...)
if jsonOutput { report.addWarnings(setup.Warnings)
warnings = append(warnings, secretWarnings...) backends := provider(setup.Environment)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, secretWarnings); err != nil {
return err
}
}
backends := provider(secretLoad.Environment)
backends.readOnlyKnownHosts = options.DryRun backends.readOnlyKnownHosts = options.DryRun
transforms := newTransformRegistry() transforms := newTransformRegistry()
if options.Stdout != nil && !jsonOutput { for _, pipeline := range setup.Config.Pipelines {
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
return err
}
}
for _, pipeline := range cfg.Pipelines {
pipelineWarnings := sshWarnings(pipeline) pipelineWarnings := sshWarnings(pipeline)
if jsonOutput { report.addWarnings(pipelineWarnings)
warnings = append(warnings, pipelineWarnings...) sourceBackend, bundles, sourceBackendName, err := openPipelineSource(ctx, backends, pipeline, sourceRoot)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, pipelineWarnings); err != nil {
return err
}
}
sourceBackend, err := backends.openSource(ctx, pipeline.Source)
if err != nil { if err != nil {
return fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err) return report, err
} }
bundles, err := bundle.Discover(ctx, sourceBackend, "") report.Pipelines = append(report.Pipelines, RunPipelineSummary{
if err != nil {
closeBackend(sourceBackend)
return fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
}
result.Pipelines = append(result.Pipelines, runPipelineResult{
ID: pipeline.ID, ID: pipeline.ID,
SourceBackend: pipeline.Source.Backend, SourceBackend: sourceBackendName,
BundleCount: len(bundles), BundleCount: len(bundles),
Destinations: destinationIDs(pipeline.Destinations), Destinations: destinationIDs(pipeline.Destinations),
Warnings: pipelineWarnings,
}) })
if options.Stdout != nil && !jsonOutput { pipelineIndex := len(report.Pipelines) - 1
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 _, destination := range pipeline.Destinations { for _, destination := range pipeline.Destinations {
selections := selectDestinationBundles(destination, bundles) processDestination(ctx, runDestinationRequest{
if isFixedPathDestination(destination) { options: options,
summary.recordFixedPath() notifier: notifier,
if options.DryRun { backends: backends,
warning := fixedPathSelectionWarning(pipeline.ID, destination.ID, selections, len(bundles)) transforms: transforms,
if jsonOutput { pipeline: pipeline,
warnings = append(warnings, warning) pipelineIndex: pipelineIndex,
} else if options.Stdout != nil { sourceBackend: sourceBackend,
if err := writeWarnings(options.Stdout, []OutputWarning{warning}); err != nil { bundles: bundles,
closeBackend(sourceBackend) destination: destination,
return err recorder: &recorder,
} })
}
}
}
if len(selections) == 0 {
continue
}
destinationBackend, err := backends.openDestination(ctx, destination)
if err != nil {
for _, selection := range selections {
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(selection.SourceBundle.RootRelativePath), err)
summary.recordFailure()
if jsonOutput {
result.Actions = append(result.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, selection.SourceBundle.RootRelativePath, err))
} else if options.Stdout != nil {
writeErrorLine(options.Stdout, selection.SourceBundle.RootRelativePath, destination.ID, destination.Backend, err)
}
}
continue
}
closeDestination := true
deferCloseDestination := func() {
if closeDestination {
closeBackend(destinationBackend)
closeDestination = false
}
}
for _, selection := range selections {
sourceBundle := selection.SourceBundle
req := publish.Request{
PipelineID: pipeline.ID,
DestinationID: destination.ID,
SourceBundle: sourceBundle,
SourceBackend: sourceBackend,
DestinationBackend: destinationBackend,
DestinationBundlePath: selection.DestinationBundlePath,
PathMapping: destination.PathMap.Mode,
Publish: *destination.Publish,
Transform: destination.Transform,
Links: destination.Links,
Transformers: transforms,
Transfer: destination.Transfer,
DistributorVersion: Version,
Force: options.Force,
}
plan, err := publish.Build(ctx, req)
if err != nil {
if plan.PipelineID == "" {
plan.PipelineID = pipeline.ID
}
if plan.DestinationID == "" {
plan.DestinationID = destination.ID
}
if plan.BundleID == "" {
plan.BundleID = sourceBundle.Manifest.ID
}
if plan.BundlePath == "" {
plan.BundlePath = sourceBundle.RootRelativePath
}
if plan.DestinationBundlePath == "" {
plan.DestinationBundlePath = selection.DestinationBundlePath
}
}
if isFixedPathDestination(destination) {
plan.PathMapping = config.PathMappingFixed
if options.DryRun && isDestructiveFixedPathAction(plan.Action) {
warning := fixedPathReplacementWarning(plan)
if jsonOutput {
warnings = append(warnings, warning)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, []OutputWarning{warning}); err != nil {
deferCloseDestination()
closeBackend(sourceBackend)
return err
}
}
}
}
if jsonOutput {
result.Actions = append(result.Actions, runActionFromPlan(destination.Backend, plan, err))
} else if options.Stdout != nil {
writePlanLine(options.Stdout, destination.Backend, plan, err)
}
if err != nil {
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 {
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 {
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
summary.recordFailure()
continue
}
}
}
}
deferCloseDestination()
} }
closeBackend(sourceBackend) closeBackend(sourceBackend)
} }
result.Summary = summary.Result() report.Summary = summary.Result()
if jsonOutput { report.OutputErrors = failures.outputErrors()
if err := WriteJSONEnvelope(options.Stdout, "run", len(failures.items) == 0, warnings, result, failures.outputErrors()); err != nil {
return err
}
} else if options.Stdout != nil {
if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil {
return err
}
}
if len(failures.items) > 0 { if len(failures.items) > 0 {
return failures return report, failures
} }
return nil return report, nil
}
type runReportRecorder struct {
report *RunReport
summary *runSummary
failures *runFailures
}
func openPipelineSource(ctx context.Context, backends *backendFactory, pipeline config.Pipeline, sourceRoot *localSourceRoot) (storage.Backend, []bundle.Bundle, string, error) {
if sourceRoot != nil && sourceRoot.pipelineID == pipeline.ID {
sourceBackend, err := backends.openLocalPath(ctx, sourceRoot.root)
if err != nil {
return nil, nil, config.BackendLocal, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, config.BackendLocal, err)
}
sourceBundle, err := bundle.Validate(ctx, sourceBackend, "")
if err != nil {
closeBackend(sourceBackend)
return nil, nil, config.BackendLocal, fmt.Errorf("pipeline %s source backend %s validate source bundle: %w", pipeline.ID, config.BackendLocal, err)
}
return sourceBackend, []bundle.Bundle{sourceBundle}, config.BackendLocal, nil
}
sourceBackend, err := backends.openSource(ctx, pipeline.Source)
if err != nil {
return nil, nil, pipeline.Source.Backend, 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 nil, nil, pipeline.Source.Backend, fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
}
return sourceBackend, bundles, pipeline.Source.Backend, nil
} }
type closeableBackend interface { type closeableBackend interface {

View File

@@ -0,0 +1,163 @@
package app
import (
"context"
"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 runDestinationRequest struct {
options RunOptions
notifier notify.Notifier
backends *backendFactory
transforms publish.TransformerResolver
pipeline config.Pipeline
pipelineIndex int
sourceBackend storage.Backend
bundles []bundle.Bundle
destination config.Destination
recorder *runReportRecorder
}
func processDestination(ctx context.Context, request runDestinationRequest) {
selections := selectDestinationBundles(request.destination, request.bundles)
if isFixedPathDestination(request.destination) {
request.recorder.summary.recordFixedPath()
if request.options.DryRun {
warning := fixedPathSelectionWarning(request.pipeline.ID, request.destination.ID, selections, len(request.bundles))
request.recorder.addPipelineWarning(request.pipelineIndex, warning)
}
}
if len(selections) == 0 {
return
}
destinationBackend, err := request.backends.openDestination(ctx, request.destination)
if err != nil {
for _, selection := range selections {
sourceBundle := selection.SourceBundle
request.recorder.recordDestinationFailure(request.pipelineIndex, runFailure{
pipelineID: request.pipeline.ID,
destinationID: request.destination.ID,
backend: request.destination.Backend,
bundlePath: sourceBundle.RootRelativePath,
err: err,
}, errorAction(request.pipeline.ID, request.destination.ID, request.destination.Backend, sourceBundle.RootRelativePath, err), true)
}
return
}
defer closeBackend(destinationBackend)
for _, selection := range selections {
processDestinationSelection(ctx, request, destinationBackend, selection)
}
}
func processDestinationSelection(ctx context.Context, request runDestinationRequest, destinationBackend storage.Backend, selection destinationBundleSelection) {
sourceBundle := selection.SourceBundle
publishRequest := publish.Request{
PipelineID: request.pipeline.ID,
DestinationID: request.destination.ID,
SourceBundle: sourceBundle,
SourceBackend: request.sourceBackend,
DestinationBackend: destinationBackend,
DestinationBundlePath: selection.DestinationBundlePath,
PathMapping: request.destination.PathMap.Mode,
Publish: *request.destination.Publish,
Transform: request.destination.Transform,
Links: request.destination.Links,
Transformers: request.transforms,
Transfer: request.destination.Transfer,
DistributorVersion: Version,
Force: request.options.Force,
}
plan, err := publish.Build(ctx, publishRequest)
if err != nil {
plan = completePlanIdentity(plan, request.pipeline, request.destination, selection)
}
if isFixedPathDestination(request.destination) {
plan.PathMapping = config.PathMappingFixed
if request.options.DryRun && isDestructiveFixedPathAction(plan.Action) {
warning := fixedPathReplacementWarning(plan)
request.recorder.addPipelineWarning(request.pipelineIndex, warning)
}
}
action := runActionFromPlan(request.destination.Backend, plan, err)
if err != nil {
request.recorder.recordDestinationFailure(request.pipelineIndex, runFailure{
pipelineID: request.pipeline.ID,
destinationID: request.destination.ID,
backend: request.destination.Backend,
bundlePath: sourceBundle.RootRelativePath,
err: err,
}, action, true)
return
}
request.recorder.addPipelineAction(request.pipelineIndex, action)
request.recorder.summary.recordPlan(plan.Action)
if request.options.DryRun {
return
}
if err := publish.Execute(ctx, publishRequest, plan); err != nil {
request.recorder.recordDestinationFailure(request.pipelineIndex, runFailure{
pipelineID: request.pipeline.ID,
destinationID: request.destination.ID,
backend: request.destination.Backend,
bundlePath: sourceBundle.RootRelativePath,
err: err,
}, RunActionRecord{}, false)
return
}
if shouldNotify(plan.Action) {
if err := request.notifier.Notify(ctx, notifyEvent(plan)); err != nil {
request.recorder.recordDestinationFailure(request.pipelineIndex, runFailure{
pipelineID: request.pipeline.ID,
destinationID: request.destination.ID,
backend: request.destination.Backend,
bundlePath: sourceBundle.RootRelativePath,
err: err,
}, RunActionRecord{}, false)
return
}
}
}
func (recorder *runReportRecorder) addPipelineWarning(pipelineIndex int, warning OutputWarning) {
recorder.report.addWarning(warning)
recorder.report.Pipelines[pipelineIndex].events = append(recorder.report.Pipelines[pipelineIndex].events, warningEvent(warning))
}
func (recorder *runReportRecorder) addPipelineAction(pipelineIndex int, action RunActionRecord) {
recorder.report.Actions = append(recorder.report.Actions, action)
recorder.report.Pipelines[pipelineIndex].events = append(recorder.report.Pipelines[pipelineIndex].events, actionEvent(len(recorder.report.Actions)-1))
}
func (recorder *runReportRecorder) recordDestinationFailure(pipelineIndex int, failure runFailure, action RunActionRecord, includeAction bool) {
recorder.failures.add(failure.pipelineID, failure.destinationID, failure.backend, storage.DisplayPath(failure.bundlePath), failure.err)
recorder.summary.recordFailure()
if includeAction {
recorder.addPipelineAction(pipelineIndex, action)
}
}
func completePlanIdentity(plan publish.Plan, pipeline config.Pipeline, destination config.Destination, selection destinationBundleSelection) publish.Plan {
if plan.PipelineID == "" {
plan.PipelineID = pipeline.ID
}
if plan.DestinationID == "" {
plan.DestinationID = destination.ID
}
if plan.BundleID == "" {
plan.BundleID = selection.SourceBundle.Manifest.ID
}
if plan.BundlePath == "" {
plan.BundlePath = selection.SourceBundle.RootRelativePath
}
if plan.DestinationBundlePath == "" {
plan.DestinationBundlePath = selection.DestinationBundlePath
}
return plan
}

View File

@@ -10,61 +10,125 @@ import (
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
func writePlanLine(w io.Writer, backend string, plan publish.Plan, planErr error) { func WriteRunReport(w io.Writer, format OutputFormat, report RunReport) error {
if w == nil { if IsJSONOutput(format) {
return return WriteJSONEnvelope(w, "run", len(report.OutputErrors) == 0, report.Warnings, report, report.OutputErrors)
} }
if planErr != nil { return writeRunReportText(w, report)
destinationID := plan.DestinationID }
func writeRunReportText(w io.Writer, report RunReport) error {
if w == nil {
return nil
}
if err := writeWarnings(w, report.PreambleWarnings); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "Configured pipelines: %d\n", len(report.Pipelines)); err != nil {
return err
}
for _, pipeline := range report.Pipelines {
if err := writeWarnings(w, pipeline.Warnings); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.SourceBackend, pipeline.BundleCount, destinationIDSummary(pipeline.Destinations)); err != nil {
return err
}
for _, event := range pipeline.events {
if event.warning != nil {
if err := writeWarnings(w, []OutputWarning{*event.warning}); err != nil {
return err
}
continue
}
if event.actionIndex < 0 || event.actionIndex >= len(report.Actions) {
continue
}
writeRunActionLine(w, report.Actions[event.actionIndex])
}
}
_, err := fmt.Fprintln(w, report.Summary.Line())
return err
}
func writeRunActionLine(w io.Writer, action RunActionRecord) {
if action.Action == "error" {
destinationID := action.DestinationID
if destinationID == "" { if destinationID == "" {
destinationID = "unknown" destinationID = "unknown"
} }
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", storage.DisplayPath(plan.BundlePath), destinationID, backend, pathMappingSummary(plan), planErr.Error()) fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", action.BundlePath, destinationID, action.Backend, pathMappingRecordSummary(action), action.Reason)
return return
} }
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", storage.DisplayPath(plan.BundlePath), plan.DestinationID, backend, pathMappingSummary(plan), plan.Action, outputSummary(plan.Outputs), plan.Reason) fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", action.BundlePath, action.DestinationID, action.Backend, pathMappingRecordSummary(action), action.Action, outputRecordSummary(action.Outputs), action.Reason)
} }
func pathMappingSummary(plan publish.Plan) string { func pathMappingRecordSummary(action RunActionRecord) string {
if plan.PathMapping != config.PathMappingFixed { if action.PathMapping != config.PathMappingFixed {
return "" return ""
} }
return fmt.Sprintf(" path_mapping=fixed target=%s", storage.DisplayPath(plan.DestinationBundlePath)) return fmt.Sprintf(" path_mapping=fixed target=%s", action.DestinationPath)
} }
func writeErrorLine(w io.Writer, bundlePath, destinationID, backend string, err error) { func outputRecordSummary(outputs []RunOutputRecord) string {
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 { if len(outputs) == 0 {
return "none" return "none"
} }
paths := make([]string, 0, len(outputs)) paths := make([]string, 0, len(outputs))
for _, output := range outputs { for _, output := range outputs {
paths = append(paths, output.DestinationPath) paths = append(paths, output.Path)
} }
return strings.Join(paths, ",") return strings.Join(paths, ",")
} }
type runResult struct { func destinationIDSummary(ids []string) string {
DryRun bool `json:"dry_run"` if len(ids) == 0 {
Pipelines []runPipelineResult `json:"pipelines"` return "none"
Actions []runActionResult `json:"actions"` }
Summary runSummaryResult `json:"summary"` return strings.Join(ids, ",")
} }
type runPipelineResult struct { type RunReport struct {
ID string `json:"id"` DryRun bool `json:"dry_run"`
SourceBackend string `json:"source_backend"` Pipelines []RunPipelineSummary `json:"pipelines"`
BundleCount int `json:"bundle_count"` Actions []RunActionRecord `json:"actions"`
Destinations []string `json:"destinations"` Summary RunSummaryCounters `json:"summary"`
Warnings []OutputWarning `json:"-"`
OutputErrors []OutputError `json:"-"`
PreambleWarnings []OutputWarning `json:"-"`
} }
type runActionResult struct { func (r *RunReport) addWarning(warning OutputWarning) {
r.Warnings = append(r.Warnings, warning)
}
func (r *RunReport) addWarnings(warnings []OutputWarning) {
r.Warnings = append(r.Warnings, warnings...)
}
type RunPipelineSummary struct {
ID string `json:"id"`
SourceBackend string `json:"source_backend"`
BundleCount int `json:"bundle_count"`
Destinations []string `json:"destinations"`
Warnings []OutputWarning `json:"-"`
events []runPipelineEvent
}
type runPipelineEvent struct {
warning *OutputWarning
actionIndex int
}
func warningEvent(warning OutputWarning) runPipelineEvent {
return runPipelineEvent{warning: &warning, actionIndex: -1}
}
func actionEvent(actionIndex int) runPipelineEvent {
return runPipelineEvent{actionIndex: actionIndex}
}
type RunActionRecord struct {
PipelineID string `json:"pipeline_id,omitempty"` PipelineID string `json:"pipeline_id,omitempty"`
DestinationID string `json:"destination_id"` DestinationID string `json:"destination_id"`
Backend string `json:"backend"` Backend string `json:"backend"`
@@ -75,10 +139,10 @@ type runActionResult struct {
Action string `json:"action"` Action string `json:"action"`
PrimaryURL string `json:"primary_url,omitempty"` PrimaryURL string `json:"primary_url,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Outputs []runOutputResult `json:"outputs"` Outputs []RunOutputRecord `json:"outputs"`
} }
type runOutputResult struct { type RunOutputRecord struct {
Path string `json:"path"` Path string `json:"path"`
Kind string `json:"kind"` Kind string `json:"kind"`
SourcePath string `json:"source_path,omitempty"` SourcePath string `json:"source_path,omitempty"`
@@ -88,13 +152,13 @@ type runOutputResult struct {
Size int64 `json:"size"` Size int64 `json:"size"`
} }
func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActionResult { func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActionRecord {
if planErr != nil { if planErr != nil {
destinationID := plan.DestinationID destinationID := plan.DestinationID
if destinationID == "" { if destinationID == "" {
destinationID = "unknown" destinationID = "unknown"
} }
return runActionResult{ return RunActionRecord{
PipelineID: plan.PipelineID, PipelineID: plan.PipelineID,
DestinationID: destinationID, DestinationID: destinationID,
Backend: backend, Backend: backend,
@@ -105,10 +169,10 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi
Action: "error", Action: "error",
PrimaryURL: plan.PrimaryURL, PrimaryURL: plan.PrimaryURL,
Reason: planErr.Error(), Reason: planErr.Error(),
Outputs: []runOutputResult{}, Outputs: []RunOutputRecord{},
} }
} }
return runActionResult{ return RunActionRecord{
PipelineID: plan.PipelineID, PipelineID: plan.PipelineID,
DestinationID: plan.DestinationID, DestinationID: plan.DestinationID,
Backend: backend, Backend: backend,
@@ -123,8 +187,8 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi
} }
} }
func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) runActionResult { func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) RunActionRecord {
return runActionResult{ return RunActionRecord{
PipelineID: pipelineID, PipelineID: pipelineID,
DestinationID: destinationID, DestinationID: destinationID,
Backend: backend, Backend: backend,
@@ -132,15 +196,15 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro
DestinationPath: storage.DisplayPath(bundlePath), DestinationPath: storage.DisplayPath(bundlePath),
Action: "error", Action: "error",
Reason: err.Error(), Reason: err.Error(),
Outputs: []runOutputResult{}, Outputs: []RunOutputRecord{},
} }
} }
func runOutputsFromPlan(outputs []publish.Output) []runOutputResult { func runOutputsFromPlan(outputs []publish.Output) []RunOutputRecord {
results := make([]runOutputResult, 0, len(outputs)) results := make([]RunOutputRecord, 0, len(outputs))
for _, output := range outputs { for _, output := range outputs {
stateOutput := output.StateOutputFile() stateOutput := output.StateOutputFile()
results = append(results, runOutputResult{ results = append(results, RunOutputRecord{
Path: stateOutput.Path, Path: stateOutput.Path,
Kind: stateOutput.Kind, Kind: stateOutput.Kind,
SourcePath: stateOutput.SourcePath, SourcePath: stateOutput.SourcePath,

View File

@@ -3,7 +3,6 @@ package app
import ( import (
"fmt" "fmt"
"sort" "sort"
"strings"
"gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/config"
@@ -78,14 +77,3 @@ func destinationIDs(destinations []config.Destination) []string {
} }
return ids return ids
} }
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, ",")
}

View File

@@ -39,15 +39,7 @@ func (s *runSummary) recordFixedPath() {
s.fixedPath++ s.fixedPath++
} }
func (s runSummary) Line() string { type RunSummaryCounters struct {
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 fixed_path=%d", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun, s.fixedPath)
}
type runSummaryResult struct {
Status string `json:"status"` Status string `json:"status"`
Planned int `json:"planned"` Planned int `json:"planned"`
PublishNew int `json:"publish_new"` PublishNew int `json:"publish_new"`
@@ -59,12 +51,16 @@ type runSummaryResult struct {
FixedPath int `json:"fixed_path"` FixedPath int `json:"fixed_path"`
} }
func (s runSummary) Result() runSummaryResult { func (s RunSummaryCounters) Line() string {
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath)
}
func (s runSummary) Result() RunSummaryCounters {
status := "ok" status := "ok"
if s.failures > 0 { if s.failures > 0 {
status = "failed" status = "failed"
} }
return runSummaryResult{ return RunSummaryCounters{
Status: status, Status: status,
Planned: s.planned, Planned: s.planned,
PublishNew: s.publishNew, PublishNew: s.publishNew,

View File

@@ -3,6 +3,7 @@ package app
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -220,6 +221,120 @@ func TestRunPublishesNewLocalBundle(t *testing.T) {
} }
} }
func TestRunPipelineWithLocalSourcePublishesConfiguredDestination(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
report, err := RunPipelineWithLocalSource(context.Background(), RunPipelineWithLocalSourceOptions{
ConfigPath: writeUploadPipelineConfig(t, destinationRoot),
PipelineID: "reports",
SourceRoot: sourceRoot,
})
if err != nil {
t.Fatalf("RunPipelineWithLocalSource() error = %v", err)
}
if got, want := report.Summary.Status, "ok"; got != want {
t.Fatalf("report status = %q, want %q", got, want)
}
if got, want := len(report.Pipelines), 1; got != want {
t.Fatalf("pipeline count = %d, want %d", got, want)
}
if got, want := report.Pipelines[0].SourceBackend, config.BackendLocal; got != want {
t.Fatalf("source backend = %q, want %q", got, want)
}
if got, want := report.Pipelines[0].BundleCount, 1; got != want {
t.Fatalf("bundle count = %d, want %d", got, want)
}
if got, want := len(report.Actions), 1; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
if report.Actions[0].PipelineID != "reports" || report.Actions[0].DestinationID != "archive" {
t.Fatalf("action = %#v, want reports/archive action", report.Actions[0])
}
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
testutil.AssertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
}
func TestRunPipelineWithLocalSourceValidatesBeforeDestinationWrites(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeJSONManifest(t, sourceRoot, testutil.ValidManifest(testutil.BundleOptions{}))
_, err := RunPipelineWithLocalSource(context.Background(), RunPipelineWithLocalSourceOptions{
ConfigPath: writeUploadPipelineConfig(t, destinationRoot),
PipelineID: "reports",
SourceRoot: sourceRoot,
})
if err == nil {
t.Fatal("RunPipelineWithLocalSource() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "validate source bundle") {
t.Fatalf("RunPipelineWithLocalSource() error = %v, want source validation context", err)
}
entries, readErr := os.ReadDir(destinationRoot)
if readErr != nil {
t.Fatalf("ReadDir() error = %v", readErr)
}
if len(entries) != 0 {
t.Fatalf("destination entries = %d, want no writes", len(entries))
}
}
func TestRunPipelineWithLocalSourcePublishesToRegisteredDestinationBackends(t *testing.T) {
sourceRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
s3Destination := fake.New()
sshDestination := fake.New()
cfg := config.Config{
Pipelines: []config.Pipeline{{
ID: "reports",
Source: config.Backend{
Backend: config.BackendHTTPUpload,
},
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",
},
},
}},
UploadTokens: []config.UploadToken{{
ID: "reporter",
TokenEnv: "UPLOAD_TOKEN",
AllowPipelines: []string{"reports"},
}},
}
config.ApplyDefaults(&cfg)
provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{
"s3:destination-bucket": s3Destination,
"ssh:/destination": sshDestination,
})
report, err := runPipelineConfigWithLocalSourceAndBackendFactory(context.Background(), cfg, RunPipelineWithLocalSourceOptions{
PipelineID: "reports",
SourceRoot: sourceRoot,
}, provider)
if err != nil {
t.Fatalf("runPipelineConfigWithLocalSourceAndBackendFactory() error = %v", err)
}
if got, want := len(report.Actions), 2; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
testutil.AssertFakeFile(t, s3Destination, "report.md", "# Report\nSunny.\n")
testutil.AssertFakeFile(t, sshDestination, "summary.txt", "Summary\n")
}
func TestRunExplicitPreserveRelativePathMappingMatchesDefault(t *testing.T) { func TestRunExplicitPreserveRelativePathMappingMatchesDefault(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
@@ -718,6 +833,220 @@ func TestRunJSONIncludesGeneratedOutputMetadata(t *testing.T) {
} }
} }
func TestBuildRunReportIncludesStructuredDryRunResults(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
configPath := testutil.WriteLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingFixed, "https://reports.example.com/latest", config.LinkPrimaryAuto, false, true, config.TransformModeIndex)
cfg, err := config.LoadFile(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{DryRun: true}, newBackendFactoryWithEnvironment)
if err != nil {
t.Fatalf("buildRunReportWithBackendFactory() error = %v", err)
}
if !report.DryRun || report.Summary.Status != "ok" || !report.Summary.DryRun {
t.Fatalf("report dry-run/status = dry_run:%t summary:%#v, want ok dry-run", report.DryRun, report.Summary)
}
if got, want := len(report.Pipelines), 1; got != want {
t.Fatalf("pipeline count = %d, want %d", got, want)
}
pipeline := report.Pipelines[0]
if pipeline.ID != "reports" || pipeline.SourceBackend != config.BackendLocal || pipeline.BundleCount != 1 || strings.Join(pipeline.Destinations, ",") != "archive" {
t.Fatalf("pipeline summary = %#v, want reports/local bundle summary", pipeline)
}
if got, want := len(report.Warnings), 1; got != want {
t.Fatalf("warning count = %d, want %d", got, want)
}
if !strings.Contains(report.Warnings[0].Message, "path_mapping=fixed candidates=1 selected_bundle=.") {
t.Fatalf("warning = %#v, want fixed path selection", report.Warnings[0])
}
if got, want := len(report.Actions), 1; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
action := report.Actions[0]
if action.PipelineID != "reports" || action.DestinationID != "archive" || action.Action != "publish_new" || action.PrimaryURL != "https://reports.example.com/latest/" {
t.Fatalf("action = %#v, want publish_new with primary URL", action)
}
if action.PathMapping != config.PathMappingFixed || action.DestinationPath != "." {
t.Fatalf("action path mapping = %q destination path = %q, want fixed root", action.PathMapping, action.DestinationPath)
}
if got, want := len(action.Outputs), 1; got != want {
t.Fatalf("output count = %d, want %d", got, want)
}
output := action.Outputs[0]
if output.Path != "index.html" || output.Kind != state.OutputKindGenerated || output.SourcePath != "report.md" || output.Transform != "markdown_to_html" || output.URL != "https://reports.example.com/latest/" {
t.Fatalf("output = %#v, want generated index metadata", output)
}
if report.Summary.Planned != 1 || report.Summary.PublishNew != 1 || report.Summary.FixedPath != 1 || report.Summary.Failed != 0 {
t.Fatalf("summary = %#v, want publish_new fixed path counters", report.Summary)
}
if len(report.OutputErrors) != 0 {
t.Fatalf("output errors = %#v, want none", report.OutputErrors)
}
}
func TestBuildRunReportIncludesPartialFailures(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)
}
cfg, err := config.LoadFile(writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination))
if err != nil {
t.Fatalf("load config: %v", err)
}
report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{}, newBackendFactoryWithEnvironment)
if err == nil || !IsPartialResultError(err) {
t.Fatalf("buildRunReportWithBackendFactory() error = %v, want partial result error", err)
}
if report.Summary.Status != "failed" || report.Summary.Planned != 1 || report.Summary.PublishNew != 1 || report.Summary.Failed != 1 {
t.Fatalf("summary = %#v, want one planned publish and one failure", report.Summary)
}
if got, want := len(report.Actions), 2; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
if report.Actions[0].DestinationID != "archive-one" || report.Actions[0].Action != "error" || !strings.Contains(report.Actions[0].Reason, "fail_unmanaged") {
t.Fatalf("first action = %#v, want archive-one error", report.Actions[0])
}
if report.Actions[1].DestinationID != "archive-two" || report.Actions[1].Action != "publish_new" {
t.Fatalf("second action = %#v, want archive-two publish_new", report.Actions[1])
}
if got, want := len(report.OutputErrors), 1; got != want {
t.Fatalf("output error count = %d, want %d", got, want)
}
outputError := report.OutputErrors[0]
if outputError.PipelineID != "reports" || outputError.DestinationID != "archive-one" || outputError.Backend != config.BackendLocal || outputError.BundlePath != "." || !strings.Contains(outputError.Message, "fail_unmanaged") {
t.Fatalf("output error = %#v, want archive-one unmanaged failure", outputError)
}
}
func TestBuildRunReportAlignsDestinationOpenFailuresForSelectedBundles(t *testing.T) {
sourceRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "daily/one", testBundleOptions{ID: "reports.one"})
writeSourceBundle(t, sourceRoot, "daily/two", testBundleOptions{ID: "reports.two", Created: testutil.DefaultCreated.Add(time.Hour)})
cfg := config.Config{Pipelines: []config.Pipeline{{
ID: "reports",
Source: config.Backend{Backend: config.BackendLocal, Path: sourceRoot},
Destinations: []config.Destination{{
ID: "object-archive",
Backend: config.BackendS3,
Endpoint: "http://s3.test",
Bucket: "missing-destination",
}},
}}}
config.ApplyDefaults(&cfg)
report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{}, fakeBackendFactoryProvider(t, nil))
if err == nil || !IsPartialResultError(err) {
t.Fatalf("buildRunReportWithBackendFactory() error = %v, want partial result error", err)
}
if report.Summary.Status != "failed" || report.Summary.Planned != 0 || report.Summary.Failed != 2 {
t.Fatalf("summary = %#v, want two destination open failures", report.Summary)
}
if got, want := len(report.Actions), 2; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
if got, want := len(report.OutputErrors), 2; got != want {
t.Fatalf("output error count = %d, want %d", got, want)
}
if got, want := len(report.Pipelines[0].events), 2; got != want {
t.Fatalf("pipeline event count = %d, want %d", got, want)
}
for index, bundlePath := range []string{"daily/one", "daily/two"} {
action := report.Actions[index]
if action.PipelineID != "reports" || action.DestinationID != "object-archive" || action.Backend != config.BackendS3 || action.BundlePath != bundlePath || action.Action != "error" {
t.Fatalf("action[%d] = %#v, want %s destination open error", index, action, bundlePath)
}
outputError := report.OutputErrors[index]
if outputError.PipelineID != action.PipelineID || outputError.DestinationID != action.DestinationID || outputError.Backend != action.Backend || outputError.BundlePath != action.BundlePath {
t.Fatalf("output error[%d] = %#v, action = %#v, want aligned identity", index, outputError, action)
}
}
}
func TestRunPipelineRunsOnlyRequestedPipeline(t *testing.T) {
firstSource := t.TempDir()
secondSource := t.TempDir()
firstDestination := t.TempDir()
secondDestination := t.TempDir()
writeSourceBundle(t, firstSource, "", testBundleOptions{ID: "reports.one"})
writeSourceBundle(t, secondSource, "", testBundleOptions{ID: "reports.two"})
configPath := writeTwoPipelineConfig(t, firstSource, firstDestination, secondSource, secondDestination)
notifier := &recordingNotifier{}
report, err := RunPipeline(context.Background(), RunPipelineOptions{
ConfigPath: configPath,
PipelineID: "reports-one",
Notifier: notifier,
})
if err != nil {
t.Fatalf("RunPipeline() error = %v", err)
}
if got, want := len(report.Pipelines), 1; got != want {
t.Fatalf("pipeline count = %d, want %d", got, want)
}
if report.Pipelines[0].ID != "reports-one" {
t.Fatalf("pipeline id = %q, want reports-one", report.Pipelines[0].ID)
}
if got, want := len(report.Actions), 1; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
if report.Actions[0].PipelineID != "reports-one" || report.Actions[0].Action != "publish_new" {
t.Fatalf("action = %#v, want reports-one publish_new", report.Actions[0])
}
if got, want := len(notifier.events), 1; got != want {
t.Fatalf("notification count = %d, want %d", got, want)
}
if notifier.events[0].PipelineID != "reports-one" {
t.Fatalf("notification pipeline = %q, want reports-one", notifier.events[0].PipelineID)
}
testutil.AssertFile(t, filepath.Join(firstDestination, "report.md"), "# Report\nSunny.\n")
if entries, err := os.ReadDir(secondDestination); err != nil || len(entries) != 0 {
t.Fatalf("second destination entries = %v err=%v, want empty", entries, err)
}
}
func TestRunPipelineUnknownIDReturnsNotFound(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
_, err := RunPipeline(context.Background(), RunPipelineOptions{
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
PipelineID: "missing",
})
if err == nil || !IsPipelineNotFound(err) {
t.Fatalf("RunPipeline() error = %v, want pipeline not found", err)
}
if !strings.Contains(err.Error(), `pipeline "missing" not found`) {
t.Fatalf("RunPipeline() error = %v, want pipeline id in message", err)
}
}
func TestRunStillRunsAllConfiguredPipelines(t *testing.T) {
firstSource := t.TempDir()
secondSource := t.TempDir()
firstDestination := t.TempDir()
secondDestination := t.TempDir()
writeSourceBundle(t, firstSource, "", testBundleOptions{ID: "reports.one"})
writeSourceBundle(t, secondSource, "", testBundleOptions{ID: "reports.two"})
err := Run(context.Background(), RunOptions{
ConfigPath: writeTwoPipelineConfig(t, firstSource, firstDestination, secondSource, secondDestination),
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
testutil.AssertFile(t, filepath.Join(firstDestination, "report.md"), "# Report\nSunny.\n")
testutil.AssertFile(t, filepath.Join(secondDestination, "report.md"), "# Report\nSunny.\n")
}
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) { func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
@@ -1346,6 +1675,48 @@ func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestina
return testutil.WriteFanoutLocalConfig(t, sourceRoot, firstDestination, secondDestination) return testutil.WriteFanoutLocalConfig(t, sourceRoot, firstDestination, secondDestination)
} }
func writeUploadPipelineConfig(t *testing.T, destinationRoot string) string {
t.Helper()
return writeConfigFile(t, `
upload_tokens:
- id: reporter
token_env: UPLOAD_TOKEN
allow_pipelines:
- reports
pipelines:
- id: reports
source:
backend: http_upload
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
`)
}
func writeTwoPipelineConfig(t *testing.T, firstSource, firstDestination, secondSource, secondDestination string) string {
t.Helper()
return writeConfigFile(t, `
pipelines:
- id: reports-one
source:
backend: local
path: `+firstSource+`
destinations:
- id: archive
backend: local
path: `+firstDestination+`
- id: reports-two
source:
backend: local
path: `+secondSource+`
destinations:
- id: archive
backend: local
path: `+secondDestination+`
`)
}
func writeConfigFile(t *testing.T, body string) string { func writeConfigFile(t *testing.T, body string) string {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
@@ -1360,6 +1731,20 @@ func writeDestinationState(t *testing.T, root, relative string, manifest bundle.
testutil.WriteDestinationState(t, root, relative, manifest, testutil.DestinationStateOptions{}) testutil.WriteDestinationState(t, root, relative, manifest, testutil.DestinationStateOptions{})
} }
func writeJSONManifest(t *testing.T, root string, manifest bundle.Manifest) {
t.Helper()
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("mkdir manifest root: %v", err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
if err := os.WriteFile(filepath.Join(root, bundle.ManifestName), append(data, '\n'), 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
}
func readStateFile(t *testing.T, path string) state.DistributorState { func readStateFile(t *testing.T, path string) state.DistributorState {
t.Helper() t.Helper()
return testutil.ReadDestinationState(t, path) return testutil.ReadDestinationState(t, path)

44
internal/app/runtime.go Normal file
View File

@@ -0,0 +1,44 @@
package app
import "gitea.maximumdirect.net/eric/distributor/internal/config"
type runtimeSetup struct {
ConfigPath string
Config config.Config
Environment config.Environment
Warnings []OutputWarning
}
func loadRuntimeSetup(configPath string) (runtimeSetup, error) {
resolvedPath := runtimeConfigPath(configPath)
cfg, err := config.LoadFile(resolvedPath)
if err != nil {
return runtimeSetup{}, err
}
return runtimeSetupFromConfig(resolvedPath, cfg)
}
func runtimeSetupFromConfig(configPath string, cfg config.Config) (runtimeSetup, error) {
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
if err != nil {
return runtimeSetup{}, err
}
return runtimeSetup{
ConfigPath: configPath,
Config: cfg,
Environment: secretLoad.Environment,
Warnings: secretConflictWarnings(secretLoad.Conflicts),
}, nil
}
func runtimeConfigPath(configPath string) string {
if configPath == "" {
return config.DefaultConfigPath
}
return configPath
}
func (setup runtimeSetup) withPipelines(pipelines []config.Pipeline) runtimeSetup {
setup.Config.Pipelines = pipelines
return setup
}

View File

@@ -0,0 +1,32 @@
package app
import (
"testing"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
)
func TestRuntimeConfigPathDefaultsEmptyPath(t *testing.T) {
if got, want := runtimeConfigPath(""), config.DefaultConfigPath; got != want {
t.Fatalf("runtimeConfigPath(\"\") = %q, want %q", got, want)
}
if got, want := runtimeConfigPath("/tmp/distributor.yml"), "/tmp/distributor.yml"; got != want {
t.Fatalf("runtimeConfigPath(explicit) = %q, want %q", got, want)
}
}
func TestLoadRuntimeSetupReturnsLoadedConfigPath(t *testing.T) {
configPath := testutil.WriteMinimalLocalConfig(t, t.TempDir(), t.TempDir())
setup, err := loadRuntimeSetup(configPath)
if err != nil {
t.Fatalf("loadRuntimeSetup() error = %v", err)
}
if setup.ConfigPath != configPath {
t.Fatalf("ConfigPath = %q, want %q", setup.ConfigPath, configPath)
}
if len(setup.Config.Pipelines) != 1 {
t.Fatalf("pipeline count = %d, want 1", len(setup.Config.Pipelines))
}
}

52
internal/app/serve.go Normal file
View File

@@ -0,0 +1,52 @@
package app
import (
"context"
"errors"
"fmt"
"net"
"net/http"
)
type ServeOptions struct {
ConfigPath string
}
func Serve(ctx context.Context, options ServeOptions) error {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
setup, err := loadRuntimeSetup(options.ConfigPath)
if err != nil {
return err
}
handler, err := newUploadHTTPHandler(ctx, setup.Config, setup.Environment)
if err != nil {
return err
}
listener, err := net.Listen("tcp", setup.Config.Server.HTTP.Bind)
if err != nil {
return fmt.Errorf("bind HTTP server %q: %w", setup.Config.Server.HTTP.Bind, err)
}
defer listener.Close()
server := &http.Server{Handler: handler}
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
_ = server.Shutdown(context.Background())
}()
err = server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
<-shutdownDone
return nil
}
return err
}

View File

@@ -0,0 +1,98 @@
package app
import (
"context"
"strings"
"testing"
)
func TestServeFailsForUnsafeUploadTokensWithoutLeakingValues(t *testing.T) {
duplicateSecret := "duplicate-secret"
tests := []struct {
name string
configPath func(*testing.T) string
env map[string]string
want string
}{
{
name: "missing token",
configPath: func(t *testing.T) string {
return writeServeUploadConfig(t, []string{"DISTRIBUTOR_TEST_MISSING_UPLOAD_TOKEN"})
},
want: "DISTRIBUTOR_TEST_MISSING_UPLOAD_TOKEN",
},
{
name: "empty token",
configPath: func(t *testing.T) string {
return writeServeUploadConfig(t, []string{"DISTRIBUTOR_TEST_EMPTY_UPLOAD_TOKEN"})
},
env: map[string]string{"DISTRIBUTOR_TEST_EMPTY_UPLOAD_TOKEN": ""},
want: "DISTRIBUTOR_TEST_EMPTY_UPLOAD_TOKEN",
},
{
name: "duplicate token",
configPath: func(t *testing.T) string {
return writeServeUploadConfig(t, []string{
"DISTRIBUTOR_TEST_FIRST_UPLOAD_TOKEN",
"DISTRIBUTOR_TEST_SECOND_UPLOAD_TOKEN",
})
},
env: map[string]string{
"DISTRIBUTOR_TEST_FIRST_UPLOAD_TOKEN": duplicateSecret,
"DISTRIBUTOR_TEST_SECOND_UPLOAD_TOKEN": duplicateSecret,
},
want: "same value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for name, value := range tt.env {
t.Setenv(name, value)
}
err := Serve(context.Background(), ServeOptions{ConfigPath: tt.configPath(t)})
if err == nil {
t.Fatal("Serve() error = nil, want token startup error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Serve() error = %v, want %q", err, tt.want)
}
if strings.Contains(err.Error(), duplicateSecret) {
t.Fatalf("Serve() error exposed token value: %v", err)
}
})
}
}
func writeServeUploadConfig(t *testing.T, tokenEnvs []string) string {
t.Helper()
body := `
server:
http:
bind: 127.0.0.1:0
upload_tokens:
`
for index, tokenEnv := range tokenEnvs {
body += `
- id: reporter-` + string(rune('a'+index)) + `
token_env: ` + tokenEnv + `
allow_pipelines:
- reports-` + string(rune('a'+index)) + `
`
}
body += `
pipelines:
`
for index := range tokenEnvs {
body += `
- id: reports-` + string(rune('a'+index)) + `
source:
backend: http_upload
destinations:
- id: archive
backend: local
path: ` + t.TempDir() + `
`
}
return writeConfigFile(t, body)
}

View File

@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/bundle"
@@ -9,6 +10,19 @@ import (
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
type PipelineNotFoundError struct {
ID string
}
func (e PipelineNotFoundError) Error() string {
return fmt.Sprintf("pipeline %q not found", e.ID)
}
func IsPipelineNotFound(err error) bool {
var notFound PipelineNotFoundError
return errors.As(err, &notFound)
}
type sourceCommandOptions struct { type sourceCommandOptions struct {
CommandName string CommandName string
Path string Path string
@@ -30,11 +44,11 @@ func selectSourceBundles(ctx context.Context, options sourceCommandOptions, prov
return sourceSelection{}, err return sourceSelection{}, err
} }
if options.ConfigPath != "" { if options.ConfigPath != "" {
cfg, err := config.LoadFile(options.ConfigPath) setup, err := loadRuntimeSetup(options.ConfigPath)
if err != nil { if err != nil {
return sourceSelection{}, err return sourceSelection{}, err
} }
return selectSourceBundlesFromConfig(ctx, cfg, options, provider) return selectSourceBundlesFromSetup(ctx, setup, options, provider)
} }
if options.PipelineID != "" { if options.PipelineID != "" {
return sourceSelection{}, fmt.Errorf("configured source mode requires --config") return sourceSelection{}, fmt.Errorf("configured source mode requires --config")
@@ -58,21 +72,25 @@ func selectSourceBundles(ctx context.Context, options sourceCommandOptions, prov
} }
func selectSourceBundlesFromConfig(ctx context.Context, cfg config.Config, options sourceCommandOptions, provider backendFactoryProvider) (sourceSelection, error) { func selectSourceBundlesFromConfig(ctx context.Context, cfg config.Config, options sourceCommandOptions, provider backendFactoryProvider) (sourceSelection, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return sourceSelection{}, err
}
return selectSourceBundlesFromSetup(ctx, setup, options, provider)
}
func selectSourceBundlesFromSetup(ctx context.Context, setup runtimeSetup, options sourceCommandOptions, provider backendFactoryProvider) (sourceSelection, error) {
if options.Path != "" { if options.Path != "" {
return sourceSelection{}, fmt.Errorf("configured source mode does not accept a local path") return sourceSelection{}, fmt.Errorf("configured source mode does not accept a local path")
} }
if options.PipelineID == "" { if options.PipelineID == "" {
return sourceSelection{}, fmt.Errorf("configured source mode requires --pipeline") return sourceSelection{}, fmt.Errorf("configured source mode requires --pipeline")
} }
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil) pipeline, ok := findPipeline(setup.Config, options.PipelineID)
if err != nil {
return sourceSelection{}, err
}
pipeline, ok := findPipeline(cfg, options.PipelineID)
if !ok { if !ok {
return sourceSelection{}, fmt.Errorf("pipeline %q not found", options.PipelineID) return sourceSelection{}, PipelineNotFoundError{ID: options.PipelineID}
} }
backends := provider(secretLoad.Environment) backends := provider(setup.Environment)
sourceBackend, err := backends.openSource(ctx, pipeline.Source) sourceBackend, err := backends.openSource(ctx, pipeline.Source)
if err != nil { if err != nil {
return sourceSelection{}, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err) return sourceSelection{}, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err)
@@ -97,7 +115,7 @@ func selectSourceBundlesFromConfig(ctx context.Context, cfg config.Config, optio
PipelineID: pipeline.ID, PipelineID: pipeline.ID,
SourceBackend: pipeline.Source.Backend, SourceBackend: pipeline.Source.Backend,
ConfigMode: true, ConfigMode: true,
Warnings: append(secretConflictWarnings(secretLoad.Conflicts), sourceSSHWarnings(pipeline)...), Warnings: append(setup.Warnings, sourceSSHWarnings(pipeline)...),
}, nil }, nil
} }

View File

@@ -0,0 +1,506 @@
package app
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"sync"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
sourcebundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
const DefaultUploadMaxFileCount = 4096
type UploadRunID string
type UploadStatus string
const (
UploadStatusAccepted UploadStatus = "accepted"
UploadStatusQueued UploadStatus = "queued"
UploadStatusRunning UploadStatus = "running"
UploadStatusSucceeded UploadStatus = "succeeded"
UploadStatusFailed UploadStatus = "failed"
UploadStatusExpired UploadStatus = "expired"
)
type UploadRunRecord struct {
ID UploadRunID `json:"run_id"`
PipelineID string `json:"pipeline_id"`
Status UploadStatus `json:"status"`
AcceptedAt time.Time `json:"accepted_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Report *RunReport `json:"report,omitempty"`
Error string `json:"error,omitempty"`
StagedRoot string `json:"-"`
}
type UploadRequest struct {
TokenID string
PipelineID string
ContentType string
Body io.Reader
IdempotencyKey string
DryRun bool
Force bool
MaxFileCount int
}
type UploadQueueFullError struct {
QueueSize int
}
func (err UploadQueueFullError) Error() string {
return fmt.Sprintf("upload queue is full with capacity %d", err.QueueSize)
}
func IsUploadQueueFull(err error) bool {
var full UploadQueueFullError
return errors.As(err, &full)
}
type UploadIdempotencyConflictError struct {
Retryable bool
}
func (err UploadIdempotencyConflictError) Error() string {
if err.Retryable {
return "upload idempotency key is already being processed"
}
return "upload idempotency key conflicts with a different source manifest"
}
func IsUploadIdempotencyConflict(err error) bool {
var conflict UploadIdempotencyConflictError
return errors.As(err, &conflict)
}
type UploadCoordinator struct {
ctx context.Context
cfg config.Config
stage uploadStageFunc
run uploadRunFunc
now func() time.Time
randomSuffix func() (string, error)
retention time.Duration
mu sync.Mutex
signal chan struct{}
queueSize int
maxConcurrency int
runningCount int
reservedCount int
activePipeline map[string]bool
pending []*uploadJob
records map[UploadRunID]UploadRunRecord
idempotency map[uploadIdempotencyScope]uploadIdempotencyRecord
}
type uploadStageFunc func(context.Context, ingest.StageOptions) (ingest.StagedBundle, error)
type uploadRunFunc func(context.Context, config.Config, RunPipelineWithLocalSourceOptions) (RunReport, error)
type uploadJob struct {
recordID UploadRunID
request UploadRequest
pipeline config.Pipeline
stagedRoot string
}
type uploadIdempotencyScope struct {
TokenID string
PipelineID string
Key string
}
type uploadIdempotencyRecord struct {
RunID UploadRunID
Manifest sourcebundle.Manifest
Pending bool
}
type uploadCoordinatorHooks struct {
stage uploadStageFunc
run uploadRunFunc
now func() time.Time
randomSuffix func() (string, error)
}
func NewUploadCoordinator(ctx context.Context, cfg config.Config) *UploadCoordinator {
return newUploadCoordinator(ctx, cfg, uploadCoordinatorHooks{})
}
func newUploadCoordinator(ctx context.Context, cfg config.Config, hooks uploadCoordinatorHooks) *UploadCoordinator {
if ctx == nil {
ctx = context.Background()
}
config.ApplyDefaults(&cfg)
stage := hooks.stage
if stage == nil {
stage = ingest.StageArchive
}
run := hooks.run
if run == nil {
run = runPipelineConfigWithLocalSource
}
now := hooks.now
if now == nil {
now = time.Now
}
randomSuffix := hooks.randomSuffix
if randomSuffix == nil {
randomSuffix = randomRunIDSuffix
}
coordinator := &UploadCoordinator{
ctx: ctx,
cfg: cfg,
stage: stage,
run: run,
now: now,
randomSuffix: randomSuffix,
retention: cfg.Server.HTTP.Retention.AsDuration(),
signal: make(chan struct{}, 1),
queueSize: cfg.Server.HTTP.QueueSize,
maxConcurrency: cfg.Server.HTTP.MaxConcurrency,
activePipeline: map[string]bool{},
records: map[UploadRunID]UploadRunRecord{},
idempotency: map[uploadIdempotencyScope]uploadIdempotencyRecord{},
}
go coordinator.dispatchLoop()
return coordinator
}
func (coordinator *UploadCoordinator) Submit(ctx context.Context, request UploadRequest) (UploadRunRecord, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return UploadRunRecord{}, err
}
if request.Body == nil {
return UploadRunRecord{}, fmt.Errorf("upload body is required")
}
pipeline, ok := findPipeline(coordinator.cfg, request.PipelineID)
if !ok {
return UploadRunRecord{}, PipelineNotFoundError{ID: request.PipelineID}
}
if pipeline.Source.Backend != config.BackendHTTPUpload {
return UploadRunRecord{}, fmt.Errorf("pipeline %s source backend %s is not configured for uploads", pipeline.ID, pipeline.Source.Backend)
}
runID, err := coordinator.newRunID(pipeline.ID)
if err != nil {
return UploadRunRecord{}, err
}
if err := ingest.ValidateContentType(request.ContentType); err != nil {
return UploadRunRecord{}, err
}
scope, hasKey := uploadRequestIdempotencyScope(request.TokenID, pipeline.ID, request.IdempotencyKey)
coordinator.mu.Lock()
coordinator.expireLocked(coordinator.now().UTC())
existingIdempotency, hasExistingIdempotency := coordinator.idempotency[scope]
if hasKey && hasExistingIdempotency && existingIdempotency.Pending {
coordinator.mu.Unlock()
return UploadRunRecord{}, UploadIdempotencyConflictError{Retryable: true}
}
needsReservation := !hasKey || !hasExistingIdempotency
if needsReservation {
if coordinator.queueFullLocked() {
coordinator.mu.Unlock()
return UploadRunRecord{}, UploadQueueFullError{QueueSize: coordinator.queueSize}
}
coordinator.reservedCount++
if hasKey {
coordinator.idempotency[scope] = uploadIdempotencyRecord{Pending: true}
}
}
coordinator.mu.Unlock()
staged, err := coordinator.stage(ctx, ingest.StageOptions{
Body: request.Body,
ContentType: request.ContentType,
PipelineStagingPath: pipeline.Source.Upload.StagingPath,
RunID: string(runID),
MaxUploadSize: int64(*pipeline.Source.Upload.MaxUploadSize),
MaxExtractedSize: int64(*pipeline.Source.Upload.MaxUploadSize),
MaxFileCount: uploadMaxFileCount(request.MaxFileCount),
})
if err != nil {
if needsReservation {
coordinator.releaseReservation(scope, hasKey)
}
return UploadRunRecord{}, err
}
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
if needsReservation {
coordinator.reservedCount--
}
if hasKey {
existingIdempotency, hasExistingIdempotency = coordinator.idempotency[scope]
if hasExistingIdempotency && !existingIdempotency.Pending {
if uploadManifestsEqual(existingIdempotency.Manifest, staged.Manifest) {
_ = os.RemoveAll(staged.Root)
record, ok := coordinator.records[existingIdempotency.RunID]
if !ok {
return UploadRunRecord{}, fmt.Errorf("idempotency record references missing run")
}
return record, nil
}
_ = os.RemoveAll(staged.Root)
return UploadRunRecord{}, UploadIdempotencyConflictError{}
}
if !hasExistingIdempotency && !needsReservation && coordinator.queueFullLocked() {
_ = os.RemoveAll(staged.Root)
return UploadRunRecord{}, UploadQueueFullError{QueueSize: coordinator.queueSize}
}
}
record := UploadRunRecord{
ID: runID,
PipelineID: pipeline.ID,
Status: UploadStatusAccepted,
AcceptedAt: coordinator.now().UTC(),
StagedRoot: staged.Root,
}
coordinator.records[runID] = record
if hasKey {
coordinator.idempotency[scope] = uploadIdempotencyRecord{
RunID: runID,
Manifest: staged.Manifest,
}
}
coordinator.pending = append(coordinator.pending, &uploadJob{
recordID: runID,
request: request,
pipeline: pipeline,
stagedRoot: staged.Root,
})
coordinator.notify()
return record, nil
}
func uploadRequestIdempotencyScope(tokenID, pipelineID, key string) (uploadIdempotencyScope, bool) {
if key == "" {
return uploadIdempotencyScope{}, false
}
return uploadIdempotencyScope{TokenID: tokenID, PipelineID: pipelineID, Key: key}, true
}
func (coordinator *UploadCoordinator) Status(runID UploadRunID) (UploadRunRecord, bool) {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
coordinator.expireLocked(coordinator.now().UTC())
record, ok := coordinator.records[runID]
return record, ok
}
func (coordinator *UploadCoordinator) Expire() []UploadRunRecord {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
return coordinator.expireLocked(coordinator.now().UTC())
}
func (coordinator *UploadCoordinator) CanAccept() bool {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
coordinator.expireLocked(coordinator.now().UTC())
return !coordinator.queueFullLocked()
}
func (coordinator *UploadCoordinator) QueueDepth() int {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
return len(coordinator.pending) + coordinator.reservedCount
}
func (coordinator *UploadCoordinator) RunningCount() int {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
return coordinator.runningCount
}
func (coordinator *UploadCoordinator) newRunID(pipelineID string) (UploadRunID, error) {
suffix, err := coordinator.randomSuffix()
if err != nil {
return "", err
}
timestamp := coordinator.now().UTC().Format("20060102T150405Z")
return UploadRunID(pipelineID + "." + timestamp + "." + suffix), nil
}
func (coordinator *UploadCoordinator) dispatchLoop() {
for {
select {
case <-coordinator.ctx.Done():
return
case <-coordinator.signal:
for coordinator.startNext() {
}
}
}
}
func (coordinator *UploadCoordinator) startNext() bool {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
if coordinator.runningCount >= coordinator.maxConcurrency {
coordinator.markPendingQueuedLocked()
return false
}
index := -1
for candidateIndex, job := range coordinator.pending {
if coordinator.activePipeline[job.pipeline.ID] {
record := coordinator.records[job.recordID]
if record.Status == UploadStatusAccepted {
record.Status = UploadStatusQueued
coordinator.records[job.recordID] = record
}
continue
}
index = candidateIndex
break
}
if index < 0 {
return false
}
job := coordinator.pending[index]
coordinator.pending = append(coordinator.pending[:index], coordinator.pending[index+1:]...)
now := coordinator.now().UTC()
record := coordinator.records[job.recordID]
record.Status = UploadStatusRunning
record.StartedAt = &now
coordinator.records[job.recordID] = record
coordinator.runningCount++
coordinator.activePipeline[job.pipeline.ID] = true
go coordinator.runJob(job)
return true
}
func (coordinator *UploadCoordinator) markPendingQueuedLocked() {
for _, job := range coordinator.pending {
record := coordinator.records[job.recordID]
if record.Status == UploadStatusAccepted {
record.Status = UploadStatusQueued
coordinator.records[job.recordID] = record
}
}
}
func (coordinator *UploadCoordinator) runJob(job *uploadJob) {
report, err := coordinator.run(coordinator.ctx, coordinator.cfg, RunPipelineWithLocalSourceOptions{
PipelineID: job.pipeline.ID,
SourceRoot: job.stagedRoot,
DryRun: job.request.DryRun,
Force: job.request.Force,
})
coordinator.complete(job, &report, err)
}
func (coordinator *UploadCoordinator) releaseReservation(scope uploadIdempotencyScope, hasKey bool) {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
coordinator.reservedCount--
if hasKey {
if record, ok := coordinator.idempotency[scope]; ok && record.Pending {
delete(coordinator.idempotency, scope)
}
}
}
func (coordinator *UploadCoordinator) queueFullLocked() bool {
return len(coordinator.pending)+coordinator.reservedCount >= coordinator.queueSize
}
func uploadMaxFileCount(value int) int {
if value > 0 {
return value
}
return DefaultUploadMaxFileCount
}
func (coordinator *UploadCoordinator) complete(job *uploadJob, report *RunReport, runErr error) {
coordinator.mu.Lock()
defer coordinator.mu.Unlock()
record := coordinator.records[job.recordID]
finishedAt := coordinator.now().UTC()
record.FinishedAt = &finishedAt
record.Report = report
if runErr != nil {
record.Status = UploadStatusFailed
record.Error = runErr.Error()
} else {
record.Status = UploadStatusSucceeded
}
coordinator.records[job.recordID] = record
coordinator.runningCount--
delete(coordinator.activePipeline, job.pipeline.ID)
coordinator.notify()
}
func (coordinator *UploadCoordinator) expireLocked(now time.Time) []UploadRunRecord {
var expired []UploadRunRecord
for runID, record := range coordinator.records {
if record.FinishedAt == nil || record.Status == UploadStatusExpired {
continue
}
if now.Before(record.FinishedAt.Add(coordinator.retention)) {
continue
}
if record.StagedRoot != "" {
_ = os.RemoveAll(record.StagedRoot)
}
record.Status = UploadStatusExpired
record.Report = nil
record.Error = ""
expired = append(expired, record)
delete(coordinator.records, runID)
for scope, idempotencyRecord := range coordinator.idempotency {
if idempotencyRecord.RunID == runID {
delete(coordinator.idempotency, scope)
}
}
}
return expired
}
func uploadManifestsEqual(a, b sourcebundle.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
}
func (coordinator *UploadCoordinator) notify() {
select {
case coordinator.signal <- struct{}{}:
default:
}
}
func randomRunIDSuffix() (string, error) {
var data [4]byte
if _, err := rand.Read(data[:]); err != nil {
return "", fmt.Errorf("generate run id suffix: %w", err)
}
return hex.EncodeToString(data[:]), nil
}

View File

@@ -0,0 +1,734 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
sourcebundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
func TestUploadCoordinatorGeneratesRunIDAndAcceptedStatus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
clock := newUploadTestClock(time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC))
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"weather-daily"},
}), uploadCoordinatorHooks{
now: clock.Now,
randomSuffix: uploadTestSuffixes("ab12cd34"),
stage: successfulUploadStage,
run: successfulUploadRun,
})
record, err := coordinator.Submit(context.Background(), UploadRequest{
PipelineID: "weather-daily",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("archive"),
})
if err != nil {
t.Fatalf("Submit() error = %v", err)
}
if got, want := record.ID, UploadRunID("weather-daily.20260603T120000Z.ab12cd34"); got != want {
t.Fatalf("run id = %q, want %q", got, want)
}
if got, want := record.Status, UploadStatusAccepted; got != want {
t.Fatalf("initial status = %q, want %q", got, want)
}
waitForUploadStatus(t, coordinator, record.ID, UploadStatusSucceeded)
}
func TestUploadCoordinatorRejectsFullQueueBeforeReadingBody(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
var reads atomic.Int64
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
queueSize: 1,
maxConcurrency: 1,
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002", "00000003"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
<-release
return RunReport{}, nil
},
})
first, err := coordinator.Submit(context.Background(), UploadRequest{
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("first"),
})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, first.ID, UploadStatusRunning)
second, err := coordinator.Submit(context.Background(), UploadRequest{
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("second"),
})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, second.ID, UploadStatusQueued)
_, err = coordinator.Submit(context.Background(), UploadRequest{
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: readerFunc(func(data []byte) (int, error) {
reads.Add(1)
return 0, io.EOF
}),
})
if err == nil || !IsUploadQueueFull(err) {
t.Fatalf("third Submit() error = %v, want full queue", err)
}
if got := reads.Load(); got != 0 {
t.Fatalf("rejected body reads = %d, want 0", got)
}
close(release)
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
waitForUploadStatus(t, coordinator, second.ID, UploadStatusSucceeded)
}
func TestUploadCoordinatorSerializesSamePipelineUploads(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
queueSize: 4,
maxConcurrency: 2,
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
<-release
return RunReport{}, nil
},
})
first, err := coordinator.Submit(context.Background(), UploadRequest{PipelineID: "reports", ContentType: ingest.ContentTypeTar, Body: strings.NewReader("first")})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
second, err := coordinator.Submit(context.Background(), UploadRequest{PipelineID: "reports", ContentType: ingest.ContentTypeTar, Body: strings.NewReader("second")})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, first.ID, UploadStatusRunning)
waitForUploadStatus(t, coordinator, second.ID, UploadStatusQueued)
if got := coordinator.RunningCount(); got != 1 {
t.Fatalf("running count = %d, want 1", got)
}
close(release)
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
waitForUploadStatus(t, coordinator, second.ID, UploadStatusSucceeded)
}
func TestUploadCoordinatorRunsDifferentPipelinesConcurrentlyUpToLimit(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports-one", "reports-two"},
queueSize: 4,
maxConcurrency: 2,
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
<-release
return RunReport{}, nil
},
})
first, err := coordinator.Submit(context.Background(), UploadRequest{PipelineID: "reports-one", ContentType: ingest.ContentTypeTar, Body: strings.NewReader("first")})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
second, err := coordinator.Submit(context.Background(), UploadRequest{PipelineID: "reports-two", ContentType: ingest.ContentTypeTar, Body: strings.NewReader("second")})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, first.ID, UploadStatusRunning)
waitForUploadStatus(t, coordinator, second.ID, UploadStatusRunning)
if got := coordinator.RunningCount(); got != 2 {
t.Fatalf("running count = %d, want 2", got)
}
close(release)
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
waitForUploadStatus(t, coordinator, second.ID, UploadStatusSucceeded)
}
func TestUploadCoordinatorRecordsFailureDetails(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runErr := errors.New("publish failed")
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
return RunReport{DryRun: options.DryRun}, runErr
},
})
record, err := coordinator.Submit(context.Background(), UploadRequest{
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("archive"),
DryRun: true,
})
if err != nil {
t.Fatalf("Submit() error = %v", err)
}
failed := waitForUploadStatus(t, coordinator, record.ID, UploadStatusFailed)
if failed.Error != runErr.Error() {
t.Fatalf("error = %q, want %q", failed.Error, runErr.Error())
}
if failed.Report == nil || !failed.Report.DryRun {
t.Fatalf("report = %#v, want retained dry-run report", failed.Report)
}
}
func TestUploadCoordinatorExpiresCompletedRecordsAndStagingDirectories(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
clock := newUploadTestClock(time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC))
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
retention: time.Second,
}), uploadCoordinatorHooks{
now: clock.Now,
randomSuffix: uploadTestSuffixes("00000001"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
return RunReport{DryRun: true}, nil
},
})
record, err := coordinator.Submit(context.Background(), UploadRequest{
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("archive"),
})
if err != nil {
t.Fatalf("Submit() error = %v", err)
}
succeeded := waitForUploadStatus(t, coordinator, record.ID, UploadStatusSucceeded)
if succeeded.Report == nil || !succeeded.Report.DryRun {
t.Fatalf("report = %#v, want retained dry-run report", succeeded.Report)
}
if _, err := os.Stat(succeeded.StagedRoot); err != nil {
t.Fatalf("staged root stat before expiry = %v", err)
}
clock.Advance(2 * time.Second)
expired := coordinator.Expire()
if got, want := len(expired), 1; got != want {
t.Fatalf("expired count = %d, want %d", got, want)
}
if expired[0].Status != UploadStatusExpired || expired[0].Report != nil || expired[0].Error != "" {
t.Fatalf("expired record = %#v, want expired without report/error", expired[0])
}
if _, ok := coordinator.Status(record.ID); ok {
t.Fatal("Status() ok = true after expiry, want removed status")
}
if _, err := os.Stat(succeeded.StagedRoot); !os.IsNotExist(err) {
t.Fatalf("staged root stat after expiry = %v, want not exist", err)
}
}
func TestUploadCoordinatorIdempotencyReturnsOriginalRunForSameManifest(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var runCount atomic.Int64
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: manifestUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
runCount.Add(1)
return RunReport{}, nil
},
})
first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "producer.retry:20260603",
})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "producer.retry:20260603",
})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if second.ID != first.ID {
t.Fatalf("second run id = %q, want original %q", second.ID, first.ID)
}
if got := runCount.Load(); got != 1 {
t.Fatalf("run count = %d, want 1", got)
}
}
func TestUploadCoordinatorIdempotencyConflictsForDifferentManifest(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: manifestUploadStage,
run: successfulUploadRun,
})
first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("one"),
IdempotencyKey: "same-key",
})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
_, err = coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("two"),
IdempotencyKey: "same-key",
})
if err == nil || !IsUploadIdempotencyConflict(err) {
t.Fatalf("second Submit() error = %v, want idempotency conflict", err)
}
}
func TestUploadCoordinatorIdempotencyIsScopedByToken(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: manifestUploadStage,
run: successfulUploadRun,
})
first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("one"),
IdempotencyKey: "shared-key",
})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-b",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("two"),
IdempotencyKey: "shared-key",
})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if second.ID == first.ID {
t.Fatalf("run ids matched across tokens: %q", second.ID)
}
}
func TestUploadCoordinatorIdempotencyIsScopedByPipeline(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports-one", "reports-two"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: manifestUploadStage,
run: successfulUploadRun,
})
first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports-one",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("one"),
IdempotencyKey: "shared-key",
})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports-two",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("two"),
IdempotencyKey: "shared-key",
})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if second.ID == first.ID {
t.Fatalf("run ids matched across pipelines: %q", second.ID)
}
}
func TestUploadCoordinatorWithoutIdempotencyKeyAcceptsDuplicateBodies(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: manifestUploadStage,
run: successfulUploadRun,
})
first, err := coordinator.Submit(context.Background(), UploadRequest{PipelineID: "reports", ContentType: ingest.ContentTypeTar, Body: strings.NewReader("same")})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
second, err := coordinator.Submit(context.Background(), UploadRequest{PipelineID: "reports", ContentType: ingest.ContentTypeTar, Body: strings.NewReader("same")})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if second.ID == first.ID {
t.Fatalf("second run id = %q, want distinct run", second.ID)
}
}
func TestUploadCoordinatorIdempotencyReturnsRetryableConflictWhileStaging(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
entered := make(chan struct{})
release := make(chan struct{})
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: func(ctx context.Context, opts ingest.StageOptions) (ingest.StagedBundle, error) {
close(entered)
<-release
return manifestUploadStage(ctx, opts)
},
run: successfulUploadRun,
})
firstErr := make(chan error, 1)
go func() {
_, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "in-flight",
})
firstErr <- err
}()
<-entered
var reads atomic.Int64
_, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: readerFunc(func(data []byte) (int, error) {
reads.Add(1)
return 0, io.EOF
}),
IdempotencyKey: "in-flight",
})
var conflict UploadIdempotencyConflictError
if err == nil || !errors.As(err, &conflict) || !conflict.Retryable {
t.Fatalf("second Submit() error = %v, want retryable idempotency conflict", err)
}
if got := reads.Load(); got != 0 {
t.Fatalf("retryable conflict body reads = %d, want 0", got)
}
close(release)
if err := <-firstErr; err != nil {
t.Fatalf("first Submit() error = %v", err)
}
}
func TestUploadCoordinatorIdempotencyPendingScopeIncludesToken(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int64
entered := make(chan struct{})
release := make(chan struct{})
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
}), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: func(ctx context.Context, opts ingest.StageOptions) (ingest.StagedBundle, error) {
if calls.Add(1) == 1 {
close(entered)
<-release
}
return manifestUploadStage(ctx, opts)
},
run: successfulUploadRun,
})
firstErr := make(chan error, 1)
go func() {
_, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "in-flight",
})
firstErr <- err
}()
<-entered
second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-b",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "in-flight",
})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if second.ID == "" {
t.Fatal("second run id is empty, want accepted run")
}
close(release)
if err := <-firstErr; err != nil {
t.Fatalf("first Submit() error = %v", err)
}
}
func TestUploadCoordinatorIdempotencyExpiresWithCompletedStatus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
clock := newUploadTestClock(time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC))
coordinator := newUploadCoordinator(ctx, uploadCoordinatorConfig(t, uploadCoordinatorConfigOptions{
pipelineIDs: []string{"reports"},
retention: time.Second,
}), uploadCoordinatorHooks{
now: clock.Now,
randomSuffix: uploadTestSuffixes("00000001", "00000002", "00000003"),
stage: manifestUploadStage,
run: successfulUploadRun,
})
first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "expires",
})
if err != nil {
t.Fatalf("first Submit() error = %v", err)
}
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
clock.Advance(2 * time.Second)
coordinator.Expire()
second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports",
ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"),
IdempotencyKey: "expires",
})
if err != nil {
t.Fatalf("second Submit() error = %v", err)
}
if second.ID == first.ID {
t.Fatalf("second run id = %q, want new run after expiry", second.ID)
}
}
type readerFunc func([]byte) (int, error)
func (fn readerFunc) Read(data []byte) (int, error) {
return fn(data)
}
func successfulUploadStage(ctx context.Context, opts ingest.StageOptions) (ingest.StagedBundle, error) {
root := filepath.Join(opts.PipelineStagingPath, opts.RunID)
if err := os.MkdirAll(root, 0o755); err != nil {
return ingest.StagedBundle{}, err
}
return ingest.StagedBundle{Root: root}, nil
}
func manifestUploadStage(ctx context.Context, opts ingest.StageOptions) (ingest.StagedBundle, error) {
data, err := io.ReadAll(opts.Body)
if err != nil {
return ingest.StagedBundle{}, err
}
root := filepath.Join(opts.PipelineStagingPath, opts.RunID)
if err := os.MkdirAll(root, 0o755); err != nil {
return ingest.StagedBundle{}, err
}
return ingest.StagedBundle{
Root: root,
Manifest: uploadTestManifest(string(data)),
}, nil
}
func uploadTestManifest(id string) sourcebundle.Manifest {
created := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
file := sourcebundle.ManifestFile{
Path: "report.md",
SHA256: sourcebundle.FileDigest([]byte(id)),
Size: int64(len(id)),
}
return sourcebundle.Manifest{
SchemaVersion: sourcebundle.SchemaVersion,
ID: id,
Created: created,
Files: []sourcebundle.ManifestFile{file},
Digest: sourcebundle.BundleDigest([]sourcebundle.ManifestFile{file}),
}
}
func successfulUploadRun(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
return RunReport{}, nil
}
type uploadCoordinatorConfigOptions struct {
pipelineIDs []string
queueSize int
maxConcurrency int
retention time.Duration
}
func uploadCoordinatorConfig(t *testing.T, opts uploadCoordinatorConfigOptions) config.Config {
t.Helper()
queueSize := opts.queueSize
if queueSize == 0 {
queueSize = 4
}
maxConcurrency := opts.maxConcurrency
if maxConcurrency == 0 {
maxConcurrency = 1
}
retentionValue := opts.retention
if retentionValue == 0 {
retentionValue = time.Minute
}
retention := config.Duration(retentionValue)
maxUploadSize := config.ByteSize(1024)
cfg := config.Config{
Server: config.Server{HTTP: config.HTTPServer{
StagingRoot: t.TempDir(),
MaxUploadSize: &maxUploadSize,
QueueSize: queueSize,
MaxConcurrency: maxConcurrency,
Retention: &retention,
}},
}
for _, pipelineID := range opts.pipelineIDs {
tokenEnv := strings.ToUpper(strings.ReplaceAll(pipelineID, "-", "_")) + "_TOKEN"
cfg.Pipelines = append(cfg.Pipelines, config.Pipeline{
ID: pipelineID,
Source: config.Backend{
Backend: config.BackendHTTPUpload,
},
Destinations: []config.Destination{{
ID: "archive",
Backend: config.BackendLocal,
Path: t.TempDir(),
}},
})
cfg.UploadTokens = append(cfg.UploadTokens, config.UploadToken{
ID: pipelineID + "-reporter",
TokenEnv: tokenEnv,
AllowPipelines: []string{pipelineID},
})
}
return cfg
}
type uploadTestClock struct {
mu sync.Mutex
now time.Time
}
func newUploadTestClock(now time.Time) *uploadTestClock {
return &uploadTestClock{now: now}
}
func (clock *uploadTestClock) Now() time.Time {
clock.mu.Lock()
defer clock.mu.Unlock()
return clock.now
}
func (clock *uploadTestClock) Advance(duration time.Duration) {
clock.mu.Lock()
defer clock.mu.Unlock()
clock.now = clock.now.Add(duration)
}
func uploadTestSuffixes(values ...string) func() (string, error) {
var mu sync.Mutex
index := 0
return func() (string, error) {
mu.Lock()
defer mu.Unlock()
if index >= len(values) {
return fmt.Sprintf("%08d", index+1), nil
}
value := values[index]
index++
return value, nil
}
}
func waitForUploadStatus(t *testing.T, coordinator *UploadCoordinator, runID UploadRunID, status UploadStatus) UploadRunRecord {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
record, ok := coordinator.Status(runID)
if ok && record.Status == status {
return record
}
time.Sleep(time.Millisecond)
}
record, ok := coordinator.Status(runID)
t.Fatalf("timed out waiting for status %s; latest ok=%t record=%#v", status, ok, record)
return UploadRunRecord{}
}

268
internal/app/upload_http.go Normal file
View File

@@ -0,0 +1,268 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
)
type uploadCoordinator interface {
CanAccept() bool
Submit(context.Context, UploadRequest) (UploadRunRecord, error)
Status(UploadRunID) (UploadRunRecord, bool)
}
type uploadHTTPHandler struct {
coordinator uploadCoordinator
tokens map[string]resolvedUploadToken
uploadPipelines map[string]struct{}
}
type resolvedUploadToken struct {
ID string
Value string
AllowedPipelines map[string]struct{}
}
type uploadAcceptedResponse struct {
RunID UploadRunID `json:"run_id"`
Status UploadStatus `json:"status"`
}
type httpErrorResponse struct {
Error string `json:"error"`
Retryable bool `json:"retryable,omitempty"`
}
const idempotencyKeyHeader = "Idempotency-Key"
func newUploadHTTPHandler(ctx context.Context, cfg config.Config, environment config.Environment) (http.Handler, error) {
config.ApplyDefaults(&cfg)
tokens, err := resolveUploadTokens(cfg, environment)
if err != nil {
return nil, err
}
return uploadHTTPHandler{
coordinator: NewUploadCoordinator(ctx, cfg),
tokens: tokens,
uploadPipelines: uploadPipelineSet(cfg),
}, nil
}
func resolveUploadTokens(cfg config.Config, environment config.Environment) (map[string]resolvedUploadToken, error) {
tokens := make(map[string]resolvedUploadToken)
for _, uploadToken := range cfg.UploadTokens {
token, ok := environment.Lookup(uploadToken.TokenEnv)
if !ok {
return nil, fmt.Errorf("upload token %s environment variable %s is not set", uploadToken.ID, uploadToken.TokenEnv)
}
if token == "" {
return nil, fmt.Errorf("upload token %s environment variable %s is empty", uploadToken.ID, uploadToken.TokenEnv)
}
if existing, exists := tokens[token]; exists {
return nil, fmt.Errorf("upload token environment variables for tokens %s and %s resolve to the same value", existing.ID, uploadToken.ID)
}
tokens[token] = resolvedUploadToken{
ID: uploadToken.ID,
Value: token,
AllowedPipelines: pipelineIDSet(uploadToken.AllowPipelines),
}
}
return tokens, nil
}
func uploadPipelineSet(cfg config.Config) map[string]struct{} {
pipelines := make(map[string]struct{})
for _, pipeline := range cfg.Pipelines {
if pipeline.Source.Backend == config.BackendHTTPUpload {
pipelines[pipeline.ID] = struct{}{}
}
}
return pipelines
}
func pipelineIDSet(ids []string) map[string]struct{} {
set := make(map[string]struct{}, len(ids))
for _, id := range ids {
set[id] = struct{}{}
}
return set
}
func (handler uploadHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/healthz":
handler.handleHealth(w)
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/pipelines/"):
handler.handleUpload(w, r)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/runs/"):
handler.handleRunStatus(w, r)
default:
writeHTTPError(w, http.StatusNotFound, "not found")
}
}
func (handler uploadHTTPHandler) handleHealth(w http.ResponseWriter) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Has("pipeline") || r.URL.Query().Has("pipeline_id") {
writeHTTPError(w, http.StatusBadRequest, "pipeline id is not accepted")
return
}
pipelineID, ok := uploadPipelineIDFromPath(r.URL.Path)
if !ok {
writeHTTPError(w, http.StatusNotFound, "not found")
return
}
if !config.IsSlugLikeID(pipelineID) {
writeHTTPError(w, http.StatusBadRequest, "invalid pipeline id")
return
}
token, ok := handler.authenticate(r.Header.Get("Authorization"))
if !ok {
writeHTTPError(w, http.StatusUnauthorized, "unauthorized")
return
}
if _, ok := handler.uploadPipelines[pipelineID]; !ok {
writeHTTPError(w, http.StatusNotFound, "upload pipeline not found")
return
}
if _, ok := token.AllowedPipelines[pipelineID]; !ok {
writeHTTPError(w, http.StatusForbidden, "forbidden")
return
}
contentType := r.Header.Get("Content-Type")
if err := ingest.ValidateContentType(contentType); err != nil {
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
return
}
idempotencyKey, err := uploadIdempotencyKey(r.Header)
if err != nil {
writeHTTPError(w, http.StatusBadRequest, "invalid idempotency key")
return
}
record, err := handler.coordinator.Submit(r.Context(), UploadRequest{
TokenID: token.ID,
PipelineID: pipelineID,
ContentType: contentType,
Body: r.Body,
IdempotencyKey: idempotencyKey,
})
if err != nil {
writeUploadSubmitError(w, err)
return
}
writeJSON(w, http.StatusAccepted, uploadAcceptedResponse{
RunID: record.ID,
Status: UploadStatusAccepted,
})
}
func uploadPipelineIDFromPath(path string) (string, bool) {
const prefix = "/v1/pipelines/"
const suffix = "/upload"
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) {
return "", false
}
pipelineID := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
if pipelineID == "" || strings.Contains(pipelineID, "/") {
return "", false
}
return pipelineID, true
}
func (handler uploadHTTPHandler) handleRunStatus(w http.ResponseWriter, r *http.Request) {
rawRunID := strings.TrimPrefix(r.URL.Path, "/runs/")
if rawRunID == "" || strings.Contains(rawRunID, "/") {
writeHTTPError(w, http.StatusNotFound, "not found")
return
}
record, ok := handler.coordinator.Status(UploadRunID(rawRunID))
if !ok {
writeHTTPError(w, http.StatusNotFound, "run not found")
return
}
writeJSON(w, http.StatusOK, record)
}
func (handler uploadHTTPHandler) authenticate(header string) (resolvedUploadToken, bool) {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return resolvedUploadToken{}, false
}
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if token == "" {
return resolvedUploadToken{}, false
}
resolved, ok := handler.tokens[token]
return resolved, ok
}
func uploadIdempotencyKey(header http.Header) (string, error) {
values := header.Values(idempotencyKeyHeader)
if len(values) == 0 {
return "", nil
}
if len(values) != 1 {
return "", fmt.Errorf("idempotency key must appear at most once")
}
key := values[0]
if key == "" {
return "", fmt.Errorf("idempotency key is required when header is present")
}
if len(key) > 128 {
return "", fmt.Errorf("idempotency key must be at most 128 bytes")
}
for index := 0; index < len(key); index++ {
character := key[index]
if character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' ||
character == '.' ||
character == '_' ||
character == '-' ||
character == ':' {
continue
}
return "", fmt.Errorf("idempotency key contains unsupported character")
}
return key, nil
}
func writeUploadSubmitError(w http.ResponseWriter, err error) {
var idempotencyConflict UploadIdempotencyConflictError
switch {
case IsUploadQueueFull(err):
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
case errors.As(err, &idempotencyConflict):
writeHTTPErrorRetryable(w, http.StatusConflict, idempotencyConflict.Error(), idempotencyConflict.Retryable)
case errors.Is(err, ingest.ErrUploadTooLarge):
writeHTTPError(w, http.StatusRequestEntityTooLarge, "upload exceeds maximum size")
case errors.Is(err, ingest.ErrUnsupportedContentType):
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
default:
writeHTTPError(w, http.StatusBadRequest, "upload rejected")
}
}
func writeHTTPError(w http.ResponseWriter, status int, message string) {
writeHTTPErrorRetryable(w, status, message, false)
}
func writeHTTPErrorRetryable(w http.ResponseWriter, status int, message string, retryable bool) {
writeJSON(w, status, httpErrorResponse{Error: message, Retryable: retryable})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}

View File

@@ -0,0 +1,772 @@
package app
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
clientupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
)
func TestHTTPUploadPublishesTarAndGzipFanout(t *testing.T) {
tests := []struct {
name string
compressed bool
contentType string
}{
{name: "tar", contentType: ingest.ContentTypeTar},
{name: "gzip", compressed: true, contentType: ingest.ContentTypeGzip},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
firstDestination := t.TempDir()
secondDestination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{firstDestination, secondDestination},
}}, 4, 1)
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"REPORTS_TOKEN": "reports-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
runID := submitHTTPUpload(t, server, "reports-secret", tt.contentType, bundleArchive(t, tt.compressed, testutil.BundleOptions{}))
record := waitForHTTPUploadStatus(t, server, runID, UploadStatusSucceeded)
if record.Report == nil {
t.Fatal("completed status report = nil, want run report")
}
if record.Report.Summary.Status != "ok" {
t.Fatalf("summary status = %q, want ok", record.Report.Summary.Status)
}
if got, want := len(record.Report.Actions), 2; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
assertPublishedBundle(t, firstDestination)
assertPublishedBundle(t, secondDestination)
})
}
}
func TestHTTPUploadInvalidArchiveIsRejectedWithoutRunID(t *testing.T) {
destination := t.TempDir()
coordinator := NewUploadCoordinator(context.Background(), httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{destination},
}}, 4, 1))
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
server := httptest.NewServer(handler)
defer server.Close()
status, body := postHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("not a tar archive"))
if status != http.StatusBadRequest {
t.Fatalf("POST upload status = %d, want %d; body = %s", status, http.StatusBadRequest, body)
}
if strings.Contains(body, "run_id") || strings.Contains(body, "reports-secret") {
t.Fatalf("invalid archive response exposed run id or token: %s", body)
}
if got := coordinator.QueueDepth(); got != 0 {
t.Fatalf("queue depth = %d, want 0", got)
}
assertDirectoryEmpty(t, destination)
}
func TestHTTPUploadIdempotencyReturnsOriginalRunForSameBundle(t *testing.T) {
destination := t.TempDir()
coordinator := NewUploadCoordinator(context.Background(), httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{destination},
}}, 4, 1))
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUploadWithKey(t, server, "reports-secret", ingest.ContentTypeTar, "same-key", bundleArchive(t, false, testutil.BundleOptions{}))
waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
secondRunID := submitHTTPUploadWithKey(t, server, "reports-secret", ingest.ContentTypeGzip, "same-key", bundleArchive(t, true, testutil.BundleOptions{}))
if secondRunID != firstRunID {
t.Fatalf("second run id = %q, want original %q", secondRunID, firstRunID)
}
if got := coordinator.QueueDepth(); got != 0 {
t.Fatalf("queue depth = %d, want no duplicate run queued", got)
}
}
func TestHTTPUploadIdempotencyReturnsConflictForDifferentBundle(t *testing.T) {
destination := t.TempDir()
coordinator := NewUploadCoordinator(context.Background(), httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{destination},
}}, 4, 1))
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUploadWithKey(t, server, "reports-secret", ingest.ContentTypeTar, "same-key", bundleArchive(t, false, testutil.BundleOptions{}))
waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
status, body := postHTTPUploadWithKey(t, server, "reports-secret", ingest.ContentTypeTar, "same-key", bundleArchive(t, false, testutil.BundleOptions{
ID: "weather.daily.brentwood.2026-05-31",
}))
if status != http.StatusConflict {
t.Fatalf("POST upload status = %d, want %d; body = %s", status, http.StatusConflict, body)
}
if strings.Contains(body, "reports-secret") {
t.Fatalf("conflict response exposed token: %s", body)
}
}
func TestHTTPUploadOversizedArchiveIsRejectedWithoutRunID(t *testing.T) {
destination := t.TempDir()
stagingPath := filepath.Join(t.TempDir(), "reports")
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: stagingPath,
destinations: []string{destination},
}}, 4, 1)
size := config.ByteSize(4)
cfg.Server.HTTP.MaxUploadSize = &size
cfg.Pipelines[0].Source.Upload.MaxUploadSize = &size
coordinator := NewUploadCoordinator(context.Background(), cfg)
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
server := httptest.NewServer(handler)
defer server.Close()
status, body := postHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{}))
if status != http.StatusRequestEntityTooLarge {
t.Fatalf("POST upload status = %d, want %d; body = %s", status, http.StatusRequestEntityTooLarge, body)
}
if strings.Contains(body, "run_id") || strings.Contains(body, "reports-secret") {
t.Fatalf("oversized response exposed run id or token: %s", body)
}
if got := coordinator.QueueDepth(); got != 0 {
t.Fatalf("queue depth = %d, want 0", got)
}
assertDirectoryEmpty(t, stagingPath)
assertDirectoryEmpty(t, destination)
}
func TestHTTPUploadSamePipelineRequestsSerialize(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
started := make(chan struct{}, 1)
coordinator := newUploadCoordinator(ctx, httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{t.TempDir()},
}}, 4, 2), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
select {
case started <- struct{}{}:
default:
}
<-release
return RunReport{}, nil
},
})
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("first"))
waitForRunStart(t, started)
first := waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusRunning)
secondRunID := submitHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("second"))
second := waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusQueued)
if first.PipelineID != "reports" || second.PipelineID != "reports" {
t.Fatalf("statuses = %#v %#v, want same pipeline", first, second)
}
if got := coordinator.RunningCount(); got != 1 {
t.Fatalf("running count = %d, want 1", got)
}
close(release)
waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
}
func TestHTTPUploadDifferentPipelinesRunConcurrently(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := make(chan struct{})
started := make(chan string, 2)
coordinator := newUploadCoordinator(ctx, httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports-one",
tokenEnv: "REPORTS_ONE_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-one"),
destinations: []string{t.TempDir()},
},
{
id: "reports-two",
tokenEnv: "REPORTS_TWO_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-two"),
destinations: []string{t.TempDir()},
},
}, 4, 2), uploadCoordinatorHooks{
randomSuffix: uploadTestSuffixes("00000001", "00000002"),
stage: successfulUploadStage,
run: func(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
started <- options.PipelineID
<-release
return RunReport{}, nil
},
})
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{
"one-secret": uploadHTTPTestToken("reports-one-reporter", "one-secret", "reports-one"),
"two-secret": uploadHTTPTestToken("reports-two-reporter", "two-secret", "reports-two"),
},
uploadPipelines: pipelineIDSet([]string{"reports-one", "reports-two"}),
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUploadToPipeline(t, server, "reports-one", "one-secret", ingest.ContentTypeTar, []byte("first"))
secondRunID := submitHTTPUploadToPipeline(t, server, "reports-two", "two-secret", ingest.ContentTypeTar, []byte("second"))
waitForStartedPipelines(t, started, "reports-one", "reports-two")
waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusRunning)
waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusRunning)
if got := coordinator.RunningCount(); got != 2 {
t.Fatalf("running count = %d, want 2", got)
}
close(release)
waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
}
func TestHTTPUploadOneTokenCanUploadToMultiplePipelines(t *testing.T) {
firstDestination := t.TempDir()
secondDestination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports-one",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-one"),
destinations: []string{firstDestination},
},
{
id: "reports-two",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-two"),
destinations: []string{secondDestination},
},
}, 4, 1)
cfg.UploadTokens = []config.UploadToken{{
ID: "shared-reporter",
TokenEnv: "SHARED_UPLOAD_TOKEN",
AllowPipelines: []string{"reports-one", "reports-two"},
}}
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"SHARED_UPLOAD_TOKEN": "shared-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUploadToPipeline(t, server, "reports-one", "shared-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{
ID: "reports.one.2026-06-08",
}))
secondRunID := submitHTTPUploadToPipeline(t, server, "reports-two", "shared-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{
ID: "reports.two.2026-06-08",
}))
first := waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
second := waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
if first.PipelineID != "reports-one" || second.PipelineID != "reports-two" {
t.Fatalf("statuses pipeline = %q/%q, want reports-one/reports-two", first.PipelineID, second.PipelineID)
}
assertPublishedBundle(t, firstDestination)
assertPublishedBundle(t, secondDestination)
}
func TestHTTPUploadMultipleTokensCanUploadToOnePipeline(t *testing.T) {
destination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "FIRST_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{destination},
}}, 4, 1)
cfg.UploadTokens = []config.UploadToken{
{ID: "first-reporter", TokenEnv: "FIRST_UPLOAD_TOKEN", AllowPipelines: []string{"reports"}},
{ID: "second-reporter", TokenEnv: "SECOND_UPLOAD_TOKEN", AllowPipelines: []string{"reports"}},
}
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"FIRST_UPLOAD_TOKEN": "first-secret",
"SECOND_UPLOAD_TOKEN": "second-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUpload(t, server, "first-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{}))
secondRunID := submitHTTPUpload(t, server, "second-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{}))
first := waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
second := waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
if first.PipelineID != "reports" || second.PipelineID != "reports" {
t.Fatalf("statuses pipeline = %q/%q, want reports/reports", first.PipelineID, second.PipelineID)
}
assertPublishedBundle(t, destination)
}
func TestHTTPUploadRejectsDisallowedPipelineAndLegacyUploadWithoutQueueing(t *testing.T) {
coordinator := NewUploadCoordinator(context.Background(), httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{t.TempDir()},
},
{
id: "private",
tokenEnv: "PRIVATE_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "private"),
destinations: []string{t.TempDir()},
},
}, 4, 1))
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{
"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports"),
},
uploadPipelines: pipelineIDSet([]string{"reports", "private"}),
}
server := httptest.NewServer(handler)
defer server.Close()
status, body := postHTTPUploadToPipeline(t, server, "private", "reports-secret", ingest.ContentTypeTar, []byte("archive"))
if status != http.StatusForbidden {
t.Fatalf("disallowed upload status = %d, want %d; body = %s", status, http.StatusForbidden, body)
}
status, body = postLegacyHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("archive"))
if status != http.StatusNotFound {
t.Fatalf("legacy upload status = %d, want %d; body = %s", status, http.StatusNotFound, body)
}
if got := coordinator.QueueDepth(); got != 0 {
t.Fatalf("queue depth = %d, want 0", got)
}
}
func TestHTTPUploadPublishesThroughSelectedPipeline(t *testing.T) {
firstDestination := t.TempDir()
secondDestination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports-one",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-one"),
destinations: []string{firstDestination},
},
{
id: "reports-two",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-two"),
destinations: []string{secondDestination},
},
}, 4, 1)
cfg.UploadTokens = []config.UploadToken{{
ID: "shared-reporter",
TokenEnv: "SHARED_UPLOAD_TOKEN",
AllowPipelines: []string{"reports-one", "reports-two"},
}}
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"SHARED_UPLOAD_TOKEN": "shared-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
bundleRoot := t.TempDir()
testutil.WriteSourceBundle(t, bundleRoot, "", testutil.BundleOptions{
ID: "reports.selected.2026-06-08",
})
client, err := clientupload.NewClient(clientupload.ClientOptions{
Endpoint: server.URL,
Token: "shared-secret",
HTTPClient: server.Client(),
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
result, err := client.UploadBundle(context.Background(), clientupload.UploadBundleOptions{
PipelineID: "reports-two",
Root: bundleRoot,
})
if err != nil {
t.Fatalf("UploadBundle() error = %v", err)
}
runID := UploadRunID(result.RunID)
record := waitForHTTPUploadStatus(t, server, runID, UploadStatusSucceeded)
if record.PipelineID != "reports-two" {
t.Fatalf("record pipeline = %q, want reports-two", record.PipelineID)
}
if record.Report == nil {
t.Fatal("completed status report = nil, want run report")
}
if got, want := len(record.Report.Pipelines), 1; got != want {
t.Fatalf("report pipeline count = %d, want %d", got, want)
}
if record.Report.Pipelines[0].ID != "reports-two" {
t.Fatalf("report pipeline = %q, want reports-two", record.Report.Pipelines[0].ID)
}
if got, want := len(record.Report.Actions), 1; got != want {
t.Fatalf("report action count = %d, want %d", got, want)
}
if record.Report.Actions[0].PipelineID != "reports-two" {
t.Fatalf("action pipeline = %q, want reports-two", record.Report.Actions[0].PipelineID)
}
assertDirectoryEmpty(t, firstDestination)
assertPublishedBundle(t, secondDestination)
}
type httpUploadPipelineSpec struct {
id string
tokenEnv string
stagingPath string
destinations []string
}
func httpUploadIntegrationConfig(t *testing.T, pipelines []httpUploadPipelineSpec, queueSize, maxConcurrency int) config.Config {
t.Helper()
size := config.ByteSize(1024 * 1024)
retention := config.Duration(time.Minute)
cfg := config.Config{
Server: config.Server{HTTP: config.HTTPServer{
Bind: config.DefaultHTTPBind,
StagingRoot: t.TempDir(),
MaxUploadSize: &size,
QueueSize: queueSize,
MaxConcurrency: maxConcurrency,
Retention: &retention,
}},
}
for _, spec := range pipelines {
pipeline := config.Pipeline{
ID: spec.id,
Source: config.Backend{
Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{
StagingPath: spec.stagingPath,
MaxUploadSize: &size,
},
},
}
for index, destination := range spec.destinations {
pipeline.Destinations = append(pipeline.Destinations, config.Destination{
ID: fmt.Sprintf("archive-%d", index+1),
Backend: config.BackendLocal,
Path: destination,
Publish: &config.PublishPolicy{Source: true},
})
}
cfg.Pipelines = append(cfg.Pipelines, pipeline)
cfg.UploadTokens = append(cfg.UploadTokens, config.UploadToken{
ID: spec.id + "-reporter",
TokenEnv: spec.tokenEnv,
AllowPipelines: []string{spec.id},
})
}
config.ApplyDefaults(&cfg)
return cfg
}
func submitHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) UploadRunID {
t.Helper()
return submitHTTPUploadToPipeline(t, server, "reports", token, contentType, body)
}
func submitHTTPUploadToPipeline(t *testing.T, server *httptest.Server, pipelineID, token, contentType string, body []byte) UploadRunID {
t.Helper()
status, responseBody := postHTTPUploadToPipeline(t, server, pipelineID, token, contentType, body)
return decodeAcceptedHTTPUpload(t, status, responseBody)
}
func submitHTTPUploadWithKey(t *testing.T, server *httptest.Server, token, contentType, key string, body []byte) UploadRunID {
t.Helper()
status, responseBody := postHTTPUploadWithKey(t, server, token, contentType, key, body)
return decodeAcceptedHTTPUpload(t, status, responseBody)
}
func decodeAcceptedHTTPUpload(t *testing.T, status int, responseBody string) UploadRunID {
t.Helper()
if status != http.StatusAccepted {
t.Fatalf("POST upload status = %d, want %d; body = %s", status, http.StatusAccepted, responseBody)
}
var accepted uploadAcceptedResponse
if err := json.Unmarshal([]byte(responseBody), &accepted); err != nil {
t.Fatalf("decode accepted response: %v", err)
}
if accepted.RunID == "" || accepted.Status != UploadStatusAccepted {
t.Fatalf("accepted response = %#v, want run id and accepted status", accepted)
}
return accepted.RunID
}
func postHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) (int, string) {
t.Helper()
return postHTTPUploadToPipeline(t, server, "reports", token, contentType, body)
}
func postHTTPUploadToPipeline(t *testing.T, server *httptest.Server, pipelineID, token, contentType string, body []byte) (int, string) {
t.Helper()
return postHTTPUploadWithKeyToPipeline(t, server, pipelineID, token, contentType, "", body)
}
func postHTTPUploadWithKey(t *testing.T, server *httptest.Server, token, contentType, key string, body []byte) (int, string) {
t.Helper()
return postHTTPUploadWithKeyToPipeline(t, server, "reports", token, contentType, key, body)
}
func postHTTPUploadWithKeyToPipeline(t *testing.T, server *httptest.Server, pipelineID, token, contentType, key string, body []byte) (int, string) {
t.Helper()
request, err := http.NewRequest(http.MethodPost, server.URL+"/v1/pipelines/"+pipelineID+"/upload", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", contentType)
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("POST upload error = %v", err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
return response.StatusCode, string(data)
}
func postLegacyHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) (int, string) {
t.Helper()
request, err := http.NewRequest(http.MethodPost, server.URL+"/upload", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", contentType)
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("POST legacy upload error = %v", err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
return response.StatusCode, string(data)
}
func waitForHTTPUploadStatus(t *testing.T, server *httptest.Server, runID UploadRunID, status UploadStatus) UploadRunRecord {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
var latest UploadRunRecord
var latestStatus int
for time.Now().Before(deadline) {
latest, latestStatus = getHTTPUploadStatus(t, server, runID)
if latestStatus == http.StatusOK && latest.Status == status {
return latest
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timed out waiting for status %s; latest HTTP status=%d record=%#v", status, latestStatus, latest)
return UploadRunRecord{}
}
func getHTTPUploadStatus(t *testing.T, server *httptest.Server, runID UploadRunID) (UploadRunRecord, int) {
t.Helper()
response, err := server.Client().Get(server.URL + "/runs/" + string(runID))
if err != nil {
t.Fatalf("GET /runs error = %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return UploadRunRecord{}, response.StatusCode
}
var record UploadRunRecord
if err := json.NewDecoder(response.Body).Decode(&record); err != nil {
t.Fatalf("decode run status: %v", err)
}
return record, response.StatusCode
}
func bundleArchive(t *testing.T, compressed bool, opts testutil.BundleOptions) []byte {
t.Helper()
root := t.TempDir()
testutil.WriteSourceBundle(t, root, "", opts)
return tarDirectory(t, root, compressed)
}
func tarDirectory(t *testing.T, root string, compressed bool) []byte {
t.Helper()
var output bytes.Buffer
var writer io.WriteCloser = nopWriteCloser{writer: &output}
if compressed {
gzipWriter := gzip.NewWriter(&output)
writer = gzipWriter
}
tarWriter := tar.NewWriter(writer)
if err := filepath.WalkDir(root, func(filePath string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
relative, err := filepath.Rel(root, filePath)
if err != nil {
return err
}
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
header := &tar.Header{
Name: filepath.ToSlash(relative),
Mode: 0o600,
Size: int64(len(data)),
}
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if _, err := tarWriter.Write(data); err != nil {
return err
}
return nil
}); err != nil {
t.Fatalf("walk bundle: %v", err)
}
if err := tarWriter.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close archive: %v", err)
}
return output.Bytes()
}
type nopWriteCloser struct {
writer io.Writer
}
func (writer nopWriteCloser) Write(data []byte) (int, error) {
return writer.writer.Write(data)
}
func (writer nopWriteCloser) Close() error {
return nil
}
func assertPublishedBundle(t *testing.T, destinationRoot string) {
t.Helper()
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
testutil.AssertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil {
t.Fatalf("destination state stat: %v", err)
}
}
func assertDirectoryEmpty(t *testing.T, root string) {
t.Helper()
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("ReadDir() error = %v", err)
}
if len(entries) != 0 {
t.Fatalf("directory %s has %d entries, want empty", root, len(entries))
}
}
func waitForRunStart(t *testing.T, started <-chan struct{}) {
t.Helper()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("timed out waiting for run start")
}
}
func waitForStartedPipelines(t *testing.T, started <-chan string, want ...string) {
t.Helper()
remaining := map[string]bool{}
for _, pipelineID := range want {
remaining[pipelineID] = true
}
deadline := time.After(time.Second)
for len(remaining) > 0 {
select {
case pipelineID := <-started:
delete(remaining, pipelineID)
case <-deadline:
t.Fatalf("timed out waiting for pipelines to start; remaining=%v", remaining)
}
}
}

View File

@@ -0,0 +1,502 @@
package app
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
)
type fakeUploadCoordinator struct {
canAccept bool
submit func(context.Context, UploadRequest) (UploadRunRecord, error)
status func(UploadRunID) (UploadRunRecord, bool)
}
func (fake fakeUploadCoordinator) CanAccept() bool {
return fake.canAccept
}
func (fake fakeUploadCoordinator) Submit(ctx context.Context, request UploadRequest) (UploadRunRecord, error) {
if fake.submit == nil {
return UploadRunRecord{}, errors.New("unexpected submit")
}
return fake.submit(ctx, request)
}
func (fake fakeUploadCoordinator) Status(runID UploadRunID) (UploadRunRecord, bool) {
if fake.status == nil {
return UploadRunRecord{}, false
}
return fake.status(runID)
}
func TestResolveUploadTokensFailsForMissingAndDuplicateTokens(t *testing.T) {
cfg := uploadHTTPTestConfig()
_, err := resolveUploadTokens(cfg, config.NewEnvironment(nil, func(string) (string, bool) {
return "", false
}))
if err == nil || !strings.Contains(err.Error(), "UPLOAD_TOKEN") {
t.Fatalf("resolveUploadTokens() error = %v, want missing UPLOAD_TOKEN", err)
}
cfg.Pipelines = append(cfg.Pipelines, config.Pipeline{
ID: "weekly",
Source: config.Backend{
Backend: config.BackendHTTPUpload,
},
Destinations: cfg.Pipelines[0].Destinations,
})
cfg.UploadTokens = append(cfg.UploadTokens, config.UploadToken{
ID: "weekly-reporter",
TokenEnv: "OTHER_UPLOAD_TOKEN",
AllowPipelines: []string{"weekly"},
})
config.ApplyDefaults(&cfg)
secret := "super-secret-token"
_, err = resolveUploadTokens(cfg, uploadHTTPTestEnvironment(map[string]string{
"UPLOAD_TOKEN": secret,
"OTHER_UPLOAD_TOKEN": secret,
}))
if err == nil {
t.Fatal("resolveUploadTokens() error = nil, want duplicate token error")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("duplicate token error exposed secret value: %q", err)
}
}
func TestResolveUploadTokensAllowsMultiplePipelines(t *testing.T) {
cfg := uploadHTTPTestConfig()
cfg.Pipelines = append(cfg.Pipelines, config.Pipeline{
ID: "weekly",
Source: config.Backend{
Backend: config.BackendHTTPUpload,
},
Destinations: cfg.Pipelines[0].Destinations,
})
cfg.UploadTokens[0].AllowPipelines = []string{"reports", "weekly"}
config.ApplyDefaults(&cfg)
tokens, err := resolveUploadTokens(cfg, uploadHTTPTestEnvironment(map[string]string{
"UPLOAD_TOKEN": "secret",
}))
if err != nil {
t.Fatalf("resolveUploadTokens() error = %v", err)
}
token, ok := tokens["secret"]
if !ok {
t.Fatal("resolved token missing")
}
if token.ID != "reporter" || token.Value != "secret" {
t.Fatalf("resolved token = %#v, want id and value", token)
}
if _, ok := token.AllowedPipelines["reports"]; !ok {
t.Fatalf("allowed pipelines = %#v, want reports", token.AllowedPipelines)
}
if _, ok := token.AllowedPipelines["weekly"]; !ok {
t.Fatalf("allowed pipelines = %#v, want weekly", token.AllowedPipelines)
}
}
func TestNewUploadHTTPHandlerAcceptsDefaultedConfig(t *testing.T) {
cfg := uploadHTTPTestConfig()
cfg.Server.HTTP.Bind = ""
cfg.Server.HTTP.StagingRoot = ""
cfg.Server.HTTP.MaxUploadSize = nil
cfg.Server.HTTP.QueueSize = 0
cfg.Server.HTTP.MaxConcurrency = 0
cfg.Server.HTTP.Retention = nil
cfg.Pipelines[0].Source.Upload.StagingPath = ""
cfg.Pipelines[0].Source.Upload.MaxUploadSize = nil
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"UPLOAD_TOKEN": "secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
if handler == nil {
t.Fatal("newUploadHTTPHandler() = nil")
}
}
func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
var submitted UploadRequest
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{
submit: func(_ context.Context, request UploadRequest) (UploadRunRecord, error) {
submitted = request
body, err := io.ReadAll(request.Body)
if err != nil {
t.Fatalf("read submitted body: %v", err)
}
if string(body) != "archive" {
t.Fatalf("submitted body = %q, want archive", body)
}
return UploadRunRecord{ID: "reports.20260603T120000Z.abcdef12", Status: UploadStatusAccepted}, nil
},
},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/pipelines/reports/upload", strings.NewReader("archive"))
request.Header.Set("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", "application/x-tar")
request.Header.Set("Idempotency-Key", "producer.retry:20260603")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, http.StatusAccepted, recorder.Body.String())
}
if submitted.PipelineID != "reports" {
t.Fatalf("submitted pipeline = %q, want reports", submitted.PipelineID)
}
if submitted.TokenID != "reporter" {
t.Fatalf("submitted token id = %q, want reporter", submitted.TokenID)
}
if submitted.IdempotencyKey != "producer.retry:20260603" {
t.Fatalf("submitted idempotency key = %q, want producer.retry:20260603", submitted.IdempotencyKey)
}
var response uploadAcceptedResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.RunID != "reports.20260603T120000Z.abcdef12" || response.Status != UploadStatusAccepted {
t.Fatalf("response = %#v, want accepted run id", response)
}
if strings.Contains(recorder.Body.String(), "valid-token") {
t.Fatalf("response exposed token: %q", recorder.Body.String())
}
}
func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) {
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
for _, authHeader := range []string{"", "Basic valid-token", "Bearer", "Bearer wrong-token"} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/pipelines/reports/upload", strings.NewReader("archive"))
request.Header.Set("Authorization", authHeader)
request.Header.Set("Content-Type", "application/x-tar")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("auth %q status = %d, want %d", authHeader, recorder.Code, http.StatusUnauthorized)
}
if strings.Contains(recorder.Body.String(), "valid-token") || strings.Contains(recorder.Body.String(), "wrong-token") {
t.Fatalf("unauthorized response exposed token: %q", recorder.Body.String())
}
}
}
func TestUploadHTTPHandlerRejectsForbiddenPipeline(t *testing.T) {
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{
submit: func(context.Context, UploadRequest) (UploadRunRecord, error) {
t.Fatal("Submit should not be called")
return UploadRunRecord{}, nil
},
},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports", "private"}),
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/pipelines/private/upload", strings.NewReader("archive"))
request.Header.Set("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", "application/x-tar")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, http.StatusForbidden, recorder.Body.String())
}
if strings.Contains(recorder.Body.String(), "valid-token") {
t.Fatalf("forbidden response exposed token: %q", recorder.Body.String())
}
}
func TestUploadHTTPHandlerRejectsInvalidPathAndRemovedLegacyUpload(t *testing.T) {
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{
submit: func(context.Context, UploadRequest) (UploadRunRecord, error) {
t.Fatal("Submit should not be called")
return UploadRunRecord{}, nil
},
},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
tests := []struct {
name string
url string
wantStatus int
}{
{name: "legacy upload", url: "/upload", wantStatus: http.StatusNotFound},
{name: "missing pipeline", url: "/v1/pipelines//upload", wantStatus: http.StatusNotFound},
{name: "extra segment", url: "/v1/pipelines/reports/upload/extra", wantStatus: http.StatusNotFound},
{name: "invalid pipeline id", url: "/v1/pipelines/.reports/upload", wantStatus: http.StatusBadRequest},
{name: "pipeline query", url: "/v1/pipelines/reports/upload?pipeline=other", wantStatus: http.StatusBadRequest},
{name: "pipeline id query", url: "/v1/pipelines/reports/upload?pipeline_id=other", wantStatus: http.StatusBadRequest},
{name: "unknown upload pipeline", url: "/v1/pipelines/missing/upload", wantStatus: http.StatusNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.url, strings.NewReader("archive"))
request.Header.Set("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", "application/x-tar")
handler.ServeHTTP(recorder, request)
if recorder.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, tt.wantStatus, recorder.Body.String())
}
})
}
}
func TestUploadHTTPHandlerRejectsUnsupportedContentTypeAndInvalidKey(t *testing.T) {
tests := []struct {
name string
url string
contentType string
keyValues []string
body io.Reader
wantStatus int
}{
{
name: "unsupported content type",
url: "/v1/pipelines/reports/upload",
contentType: "application/zip",
body: strings.NewReader("archive"),
wantStatus: http.StatusUnsupportedMediaType,
},
{
name: "invalid key syntax",
url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar",
keyValues: []string{"bad key"},
body: strings.NewReader("archive"),
wantStatus: http.StatusBadRequest,
},
{
name: "empty key",
url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar",
keyValues: []string{""},
body: strings.NewReader("archive"),
wantStatus: http.StatusBadRequest,
},
{
name: "too long key",
url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar",
keyValues: []string{strings.Repeat("a", 129)},
body: strings.NewReader("archive"),
wantStatus: http.StatusBadRequest,
},
{
name: "multiple keys",
url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar",
keyValues: []string{"one", "two"},
body: strings.NewReader("archive"),
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{
submit: func(context.Context, UploadRequest) (UploadRunRecord, error) {
t.Fatal("Submit should not be called")
return UploadRunRecord{}, nil
},
},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.url, tt.body)
request.Header.Set("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", tt.contentType)
for _, value := range tt.keyValues {
request.Header.Add("Idempotency-Key", value)
}
handler.ServeHTTP(recorder, request)
if recorder.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, tt.wantStatus, recorder.Body.String())
}
})
}
}
func TestUploadHTTPHandlerMapsSubmitErrors(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
wantBody string
}{
{name: "oversized", err: ingest.ErrUploadTooLarge, wantStatus: http.StatusRequestEntityTooLarge},
{name: "unsupported", err: ingest.ErrUnsupportedContentType, wantStatus: http.StatusUnsupportedMediaType},
{name: "full queue", err: UploadQueueFullError{QueueSize: 1}, wantStatus: http.StatusServiceUnavailable},
{name: "idempotency conflict", err: UploadIdempotencyConflictError{}, wantStatus: http.StatusConflict, wantBody: "different source manifest"},
{name: "idempotency in progress", err: UploadIdempotencyConflictError{Retryable: true}, wantStatus: http.StatusConflict, wantBody: `"retryable":true`},
{name: "malformed", err: errors.New("malformed archive"), wantStatus: http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{
canAccept: true,
submit: func(context.Context, UploadRequest) (UploadRunRecord, error) {
return UploadRunRecord{}, tt.err
},
},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/pipelines/reports/upload", strings.NewReader("archive"))
request.Header.Set("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", "application/x-tar")
handler.ServeHTTP(recorder, request)
if recorder.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, tt.wantStatus, recorder.Body.String())
}
if tt.wantBody != "" && !strings.Contains(recorder.Body.String(), tt.wantBody) {
t.Fatalf("body = %q, want substring %q", recorder.Body.String(), tt.wantBody)
}
})
}
}
func TestUploadHTTPHandlerRunStatusAndHealth(t *testing.T) {
finishedAt := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{
canAccept: true,
status: func(runID UploadRunID) (UploadRunRecord, bool) {
if runID != "reports.20260603T120000Z.abcdef12" {
return UploadRunRecord{}, false
}
return UploadRunRecord{
ID: runID,
PipelineID: "reports",
Status: UploadStatusSucceeded,
FinishedAt: &finishedAt,
}, true
},
},
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("health status = %d, want %d", recorder.Code, http.StatusOK)
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/runs/reports.20260603T120000Z.abcdef12", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("run status = %d, want %d; body = %q", recorder.Code, http.StatusOK, recorder.Body.String())
}
var record UploadRunRecord
if err := json.Unmarshal(recorder.Body.Bytes(), &record); err != nil {
t.Fatalf("decode run status: %v", err)
}
if record.ID != "reports.20260603T120000Z.abcdef12" || record.Status != UploadStatusSucceeded {
t.Fatalf("record = %#v, want succeeded run status", record)
}
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/runs/unknown", nil))
if recorder.Code != http.StatusNotFound {
t.Fatalf("unknown run status = %d, want %d", recorder.Code, http.StatusNotFound)
}
}
type countingReader struct {
reader io.Reader
reads int
}
func (reader *countingReader) Read(data []byte) (int, error) {
reader.reads++
return reader.reader.Read(data)
}
func uploadHTTPTestConfig() config.Config {
size := config.ByteSize(1024)
retention := config.Duration(24 * time.Hour)
cfg := config.Config{
Server: config.Server{HTTP: config.HTTPServer{
Bind: config.DefaultHTTPBind,
StagingRoot: "/tmp/distributor-test",
MaxUploadSize: &size,
QueueSize: 2,
MaxConcurrency: 1,
Retention: &retention,
}},
Pipelines: []config.Pipeline{{
ID: "reports",
Source: config.Backend{
Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{
StagingPath: "/tmp/distributor-test/reports",
MaxUploadSize: &size,
},
},
Destinations: []config.Destination{{
ID: "local",
Backend: config.BackendLocal,
Path: "/tmp/distributor-output",
Publish: &config.PublishPolicy{Source: true},
}},
}},
UploadTokens: []config.UploadToken{{
ID: "reporter",
TokenEnv: "UPLOAD_TOKEN",
AllowPipelines: []string{"reports"},
}},
}
config.ApplyDefaults(&cfg)
return cfg
}
func uploadHTTPTestEnvironment(values map[string]string) config.Environment {
return config.NewEnvironment(values, func(string) (string, bool) {
return "", false
})
}
func uploadHTTPTestToken(id, value string, pipelines ...string) resolvedUploadToken {
return resolvedUploadToken{
ID: id,
Value: value,
AllowedPipelines: pipelineIDSet(pipelines),
}
}

View File

@@ -6,7 +6,6 @@ import (
"io" "io"
"gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
type ValidateOptions struct { type ValidateOptions struct {
@@ -73,29 +72,17 @@ func writeValidateResult(options ValidateOptions, selection sourceSelection) err
} }
type validateResult struct { type validateResult struct {
PipelineID string `json:"pipeline_id,omitempty"` PipelineID string `json:"pipeline_id,omitempty"`
SourceBackend string `json:"source_backend,omitempty"` SourceBackend string `json:"source_backend,omitempty"`
BundleCount int `json:"bundle_count"` BundleCount int `json:"bundle_count"`
Bundles []validateBundleResult `json:"bundles"` Bundles []bundleSummaryResult `json:"bundles"`
}
type validateBundleResult struct {
Path string `json:"path"`
ID string `json:"id"`
} }
func validateResultFromSelection(selection sourceSelection) validateResult { func validateResultFromSelection(selection sourceSelection) validateResult {
result := validateResult{ return validateResult{
PipelineID: selection.PipelineID, PipelineID: selection.PipelineID,
SourceBackend: selection.SourceBackend, SourceBackend: selection.SourceBackend,
BundleCount: len(selection.Bundles), BundleCount: len(selection.Bundles),
Bundles: make([]validateBundleResult, 0, len(selection.Bundles)), Bundles: bundleSummariesFromBundles(selection.Bundles),
} }
for _, sourceBundle := range selection.Bundles {
result.Bundles = append(result.Bundles, validateBundleResult{
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
ID: sourceBundle.Manifest.ID,
})
}
return result
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -183,6 +184,51 @@ pipelines:
} }
} }
func TestValidateConfiguredSourcePrintsSecretConflictWarningWithoutValues(t *testing.T) {
name := "DISTRIBUTOR_TEST_VALIDATE_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)
}
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
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 := Validate(context.Background(), ValidateOptions{
ConfigPath: configPath,
PipelineID: "reports",
Stdout: &stdout,
})
if err != nil {
t.Fatalf("Validate() 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, "Validated 1 bundle(s) for pipeline reports source local") {
t.Fatalf("stdout = %q, want validate summary", output)
}
if strings.Contains(output, "process-value") || strings.Contains(output, "secret-value") {
t.Fatalf("stdout exposed secret values: %q", output)
}
}
func TestValidateConfiguredSourceRequiresPipeline(t *testing.T) { func TestValidateConfiguredSourceRequiresPipeline(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()

View File

@@ -70,11 +70,19 @@ func TestParseManifestRejectsInvalidDigestFormat(t *testing.T) {
func TestParseManifestRejectsUnsafeFilePaths(t *testing.T) { func TestParseManifestRejectsUnsafeFilePaths(t *testing.T) {
tests := []string{ tests := []string{
`"path": ""`,
`"path": "."`,
`"path": "./report.md"`,
`"path": "../report.md"`, `"path": "../report.md"`,
`"path": "/report.md"`, `"path": "/report.md"`,
`"path": "nested/../report.md"`, `"path": "nested/../report.md"`,
`"path": "nested/./report.md"`,
`"path": "nested//report.md"`,
`"path": "nested\\report.md"`,
`"path": "manifest.json"`, `"path": "manifest.json"`,
`"path": "nested/manifest.json"`,
`"path": "` + storage.StateFileName + `"`, `"path": "` + storage.StateFileName + `"`,
`"path": "nested/` + storage.StateFileName + `"`,
} }
for _, replacement := range tests { for _, replacement := range tests {
t.Run(replacement, func(t *testing.T) { t.Run(replacement, func(t *testing.T) {
@@ -128,6 +136,16 @@ func TestValidateManifestRejectsInvalidManifest(t *testing.T) {
manifest.Digest = BundleDigest(manifest.Files) manifest.Digest = BundleDigest(manifest.Files)
return manifest return manifest
}, },
"nested manifest path": func(manifest Manifest) Manifest {
manifest.Files[0].Path = "nested/manifest.json"
manifest.Digest = BundleDigest(manifest.Files)
return manifest
},
"nested state path": func(manifest Manifest) Manifest {
manifest.Files[0].Path = "nested/" + storage.StateFileName
manifest.Digest = BundleDigest(manifest.Files)
return manifest
},
"duplicate path": func(manifest Manifest) Manifest { "duplicate path": func(manifest Manifest) Manifest {
manifest.Files[1].Path = manifest.Files[0].Path manifest.Files[1].Path = manifest.Files[0].Path
manifest.Digest = BundleDigest(manifest.Files) manifest.Digest = BundleDigest(manifest.Files)

View File

@@ -65,6 +65,38 @@ func TestValidateRejectsSymlinkFile(t *testing.T) {
assertErrorContains(t, err, "regular file") assertErrorContains(t, err, "regular file")
} }
func TestValidateRejectsUnsafeManifestPaths(t *testing.T) {
tests := []string{
"",
".",
"./report.md",
"../report.md",
"/report.md",
"nested/../report.md",
"nested/./report.md",
"nested//report.md",
`nested\report.md`,
ManifestName,
storage.StateFileName,
"nested/" + ManifestName,
"nested/" + storage.StateFileName,
}
for _, path := range tests {
t.Run(path, func(t *testing.T) {
backend := validFakeBundle(t)
manifest := validFixtureManifest(t)
manifest.Files[0].Path = path
manifest.Digest = BundleDigest(manifest.Files)
writeManifest(t, backend, manifest)
_, err := Validate(context.Background(), backend, "")
if err == nil {
t.Fatal("Validate() error = nil, want unsafe path error")
}
})
}
}
func validFakeBundle(t *testing.T) *fake.Backend { func validFakeBundle(t *testing.T) *fake.Backend {
t.Helper() t.Helper()
backend := fake.New() backend := fake.New()

12
internal/cli/flags.go Normal file
View File

@@ -0,0 +1,12 @@
package cli
import (
"flag"
"io"
)
func newFlagSet(name string, stderr io.Writer) *flag.FlagSet {
flags := flag.NewFlagSet(name, flag.ContinueOnError)
flags.SetOutput(stderr)
return flags
}

View File

@@ -2,7 +2,6 @@ package cli
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -30,8 +29,7 @@ func manifestCreateCommand(ctx context.Context, args []string, stdout, stderr io
printManifestCreateHelp(stdout) printManifestCreateHelp(stdout)
return exitOK return exitOK
} }
flags := flag.NewFlagSet("manifest create", flag.ContinueOnError) flags := newFlagSet("manifest create", stderr)
flags.SetOutput(stderr)
id := flags.String("id", "", "source bundle id") id := flags.String("id", "", "source bundle id")
created := flags.String("created", "", "source created timestamp") created := flags.String("created", "", "source created timestamp")
overwrite := flags.Bool("overwrite", false, "replace an existing manifest.json") overwrite := flags.Bool("overwrite", false, "replace an existing manifest.json")

View File

@@ -29,6 +29,8 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return versionCommand(ctx, args[1:], stdout, stderr) return versionCommand(ctx, args[1:], stdout, stderr)
case "run": case "run":
return runCommand(ctx, args[1:], stdout, stderr) return runCommand(ctx, args[1:], stdout, stderr)
case "serve":
return serveCommand(ctx, args[1:], stdout, stderr)
case "validate": case "validate":
return validateCommand(ctx, args[1:], stdout, stderr) return validateCommand(ctx, args[1:], stdout, stderr)
case "inspect": case "inspect":
@@ -51,6 +53,7 @@ Usage:
Commands: Commands:
version Print version information version Print version information
run Run configured distribution pipelines run Run configured distribution pipelines
serve Run the HTTP upload server
validate Validate a source bundle or bundle tree validate Validate a source bundle or bundle tree
inspect Inspect bundles or distributor state inspect Inspect bundles or distributor state
manifest Create source bundle manifests manifest Create source bundle manifests

View File

@@ -11,6 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/distributor/internal/app"
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/testutil" "gitea.maximumdirect.net/eric/distributor/internal/testutil"
producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle" producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
@@ -92,6 +93,34 @@ func TestExecuteVersionJSON(t *testing.T) {
} }
} }
func TestExecuteServeParsesConfig(t *testing.T) {
originalServeApp := serveApp
defer func() {
serveApp = originalServeApp
}()
var gotOptions app.ServeOptions
serveApp = func(_ context.Context, options app.ServeOptions) error {
gotOptions = options
return nil
}
var stdout, stderr bytes.Buffer
code := Execute(context.Background(), []string{"serve", "--config", "config.yml"}, &stdout, &stderr)
if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
}
if gotOptions.ConfigPath != "config.yml" {
t.Fatalf("ConfigPath = %q, want config.yml", gotOptions.ConfigPath)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteRejectsInvalidFormat(t *testing.T) { func TestExecuteRejectsInvalidFormat(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -603,8 +632,12 @@ func TestExecuteRunDryRun(t *testing.T) {
if code != exitOK { if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
} }
if !strings.Contains(stdout.String(), "action=publish_new") { wantStdout := "Configured pipelines: 1\n" +
t.Fatalf("stdout = %q, want config summary", stdout.String()) "- pipeline=reports source=local bundles=1 destinations=archive\n" +
" - bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt reason=\"destination state is absent\"\n" +
"Final status: ok planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true fixed_path=0\n"
if got := stdout.String(); got != wantStdout {
t.Fatalf("stdout = %q, want %q", got, wantStdout)
} }
if stderr.Len() != 0 { if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String()) t.Fatalf("stderr = %q, want empty", stderr.String())
@@ -631,6 +664,14 @@ func TestExecuteRunJSONDryRun(t *testing.T) {
if result["dry_run"] != true { if result["dry_run"] != true {
t.Fatalf("result = %#v, want dry_run true", result) t.Fatalf("result = %#v, want dry_run true", result)
} }
pipelines, ok := result["pipelines"].([]any)
if !ok || len(pipelines) != 1 {
t.Fatalf("pipelines = %#v, want one pipeline", result["pipelines"])
}
pipeline, ok := pipelines[0].(map[string]any)
if !ok || pipeline["id"] != "reports" || pipeline["source_backend"] != "local" || pipeline["bundle_count"] != float64(1) {
t.Fatalf("pipeline = %#v, want reports/local summary", pipelines[0])
}
actions, ok := result["actions"].([]any) actions, ok := result["actions"].([]any)
if !ok || len(actions) != 1 { if !ok || len(actions) != 1 {
t.Fatalf("actions = %#v, want one action", result["actions"]) t.Fatalf("actions = %#v, want one action", result["actions"])
@@ -639,6 +680,14 @@ func TestExecuteRunJSONDryRun(t *testing.T) {
if !ok || action["action"] != "publish_new" { if !ok || action["action"] != "publish_new" {
t.Fatalf("action = %#v, want publish_new", actions[0]) t.Fatalf("action = %#v, want publish_new", actions[0])
} }
outputs, ok := action["outputs"].([]any)
if !ok || len(outputs) != 2 {
t.Fatalf("outputs = %#v, want source outputs", action["outputs"])
}
summary, ok := result["summary"].(map[string]any)
if !ok || summary["status"] != "ok" || summary["planned"] != float64(1) || summary["publish_new"] != float64(1) || summary["dry_run"] != true {
t.Fatalf("summary = %#v, want ok dry-run publish counters", result["summary"])
}
if stderr.Len() != 0 { if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String()) t.Fatalf("stderr = %q, want empty", stderr.String())
} }

View File

@@ -2,7 +2,6 @@ package cli
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"io" "io"
@@ -15,8 +14,7 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in
return exitOK return exitOK
} }
flags := flag.NewFlagSet("run", flag.ContinueOnError) flags := newFlagSet("run", stderr)
flags.SetOutput(stderr)
configPath := flags.String("config", "", "path to config file") configPath := flags.String("config", "", "path to config file")
dryRun := flags.Bool("dry-run", false, "load and validate config without publishing") dryRun := flags.Bool("dry-run", false, "load and validate config without publishing")
force := flags.Bool("force", false, "allow explicit destructive replacement for supported conflicts") force := flags.Bool("force", false, "allow explicit destructive replacement for supported conflicts")

44
internal/cli/serve.go Normal file
View File

@@ -0,0 +1,44 @@
package cli
import (
"context"
"fmt"
"io"
"gitea.maximumdirect.net/eric/distributor/internal/app"
)
var serveApp = app.Serve
func serveCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if hasHelp(args) {
printServeHelp(stdout)
return exitOK
}
flags := newFlagSet("serve", stderr)
configPath := flags.String("config", "", "path to config file")
if err := flags.Parse(args); err != nil {
return exitUsage
}
if rejectPositionalArgs(stderr, "serve", flags.Args()) {
return exitUsage
}
if err := serveApp(ctx, app.ServeOptions{ConfigPath: *configPath}); err != nil {
return fail(stderr, err)
}
return exitOK
}
func printServeHelp(w io.Writer) {
fmt.Fprint(w, `Usage:
distributor serve --config <path>
Options:
--config <path> Path to config file
Serve loads configured HTTP upload sources, resolves upload tokens through the
configured secret environment, and starts the HTTP upload API.
`)
}

View File

@@ -1,7 +1,6 @@
package cli package cli
import ( import (
"flag"
"fmt" "fmt"
"io" "io"
@@ -17,8 +16,7 @@ type sourceDiagnosticArgs struct {
} }
func parseSourceDiagnosticArgs(stderr io.Writer, command string, args []string) (sourceDiagnosticArgs, bool) { func parseSourceDiagnosticArgs(stderr io.Writer, command string, args []string) (sourceDiagnosticArgs, bool) {
flags := flag.NewFlagSet(command, flag.ContinueOnError) flags := newFlagSet(command, stderr)
flags.SetOutput(stderr)
configPath := flags.String("config", "", "path to config file") configPath := flags.String("config", "", "path to config file")
pipelineID := flags.String("pipeline", "", "pipeline id") pipelineID := flags.String("pipeline", "", "pipeline id")
bundlePath := flags.String("bundle", "", "source-root-relative bundle path") bundlePath := flags.String("bundle", "", "source-root-relative bundle path")

View File

@@ -2,7 +2,6 @@ package cli
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"io" "io"
@@ -14,8 +13,7 @@ func versionCommand(_ context.Context, args []string, stdout, stderr io.Writer)
printVersionHelp(stdout) printVersionHelp(stdout)
return exitOK return exitOK
} }
flags := flag.NewFlagSet("version", flag.ContinueOnError) flags := newFlagSet("version", stderr)
flags.SetOutput(stderr)
formatFlag := addFormatFlag(flags) formatFlag := addFormatFlag(flags)
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
return exitUsage return exitUsage

View File

@@ -0,0 +1,50 @@
package config
type backendView struct {
Backend string
Host string
User string
Port int
Path string
Endpoint string
Bucket string
Prefix string
Region string
ForcePath *bool
Creds Credentials
SSH SSH
}
func backendViewFromSource(source Backend) backendView {
return backendView{
Backend: source.Backend,
Host: source.Host,
User: source.User,
Port: source.Port,
Path: source.Path,
Endpoint: source.Endpoint,
Bucket: source.Bucket,
Prefix: source.Prefix,
Region: source.Region,
ForcePath: source.ForcePath,
Creds: source.Creds,
SSH: source.SSH,
}
}
func backendViewFromDestination(destination Destination) backendView {
return backendView{
Backend: destination.Backend,
Host: destination.Host,
User: destination.User,
Port: destination.Port,
Path: destination.Path,
Endpoint: destination.Endpoint,
Bucket: destination.Bucket,
Prefix: destination.Prefix,
Region: destination.Region,
ForcePath: destination.ForcePath,
Creds: destination.Creds,
SSH: destination.SSH,
}
}

View File

@@ -0,0 +1,190 @@
package config
import "testing"
func TestBackendViewsPreserveEquivalentStorageFields(t *testing.T) {
forcePathStyle := false
source := Backend{
Backend: BackendS3,
Host: "storage.example.com",
User: "reports",
Port: 2222,
Path: "/reports",
Endpoint: "https://s3.example.com",
Bucket: "source",
Prefix: "incoming",
Region: "us-west-2",
ForcePath: &forcePathStyle,
Creds: Credentials{
AccessKeyIDEnv: "ACCESS_KEY_ID",
SecretAccessKeyEnv: "SECRET_ACCESS_KEY",
},
SSH: SSH{
KeyFile: "/home/reports/.ssh/id_ed25519",
KnownHosts: "/home/reports/.ssh/known_hosts",
HostKeyPolicy: HostKeyPolicyStrict,
},
}
destination := Destination{
Backend: source.Backend,
Host: source.Host,
User: source.User,
Port: source.Port,
Path: source.Path,
Endpoint: source.Endpoint,
Bucket: source.Bucket,
Prefix: source.Prefix,
Region: source.Region,
ForcePath: source.ForcePath,
Creds: source.Creds,
SSH: source.SSH,
}
sourceView := backendViewFromSource(source)
destinationView := backendViewFromDestination(destination)
if sourceView != destinationView {
t.Fatalf("source view = %#v, destination view = %#v, want equivalent storage fields", sourceView, destinationView)
}
}
func TestBackendViewValidationKeepsHTTPUploadSourceOnly(t *testing.T) {
cfg := Config{Pipelines: []Pipeline{{
ID: "reports",
Source: Backend{
Backend: BackendHTTPUpload,
},
Destinations: []Destination{{
ID: "archive",
Backend: BackendHTTPUpload,
}},
}}, UploadTokens: []UploadToken{{
ID: "reporter",
TokenEnv: "UPLOAD_TOKEN",
AllowPipelines: []string{"reports"},
}}}
ApplyDefaults(&cfg)
err := Validate(cfg)
if err == nil {
t.Fatal("Validate() error = nil, want destination http_upload error")
}
if got, want := err.Error(), "pipelines[0].destinations[0].backend http_upload is only supported for sources"; got != want {
t.Fatalf("Validate() error = %q, want %q", got, want)
}
}
func TestBackendViewValidationAppliesStorageRulesToSourcesAndDestinations(t *testing.T) {
forcePathStyle := false
tests := []struct {
name string
source Backend
destination Destination
wantErr bool
}{
{
name: "local valid",
source: Backend{
Backend: BackendLocal,
Path: "/source",
},
destination: Destination{
Backend: BackendLocal,
Path: "/destination",
},
},
{
name: "local missing path",
source: Backend{
Backend: BackendLocal,
},
destination: Destination{
Backend: BackendLocal,
},
wantErr: true,
},
{
name: "ssh valid",
source: Backend{
Backend: BackendSSH,
Host: "source.example.com",
Port: 22,
Path: "/source",
SSH: SSH{HostKeyPolicy: HostKeyPolicyAcceptNew},
},
destination: Destination{
Backend: BackendSSH,
Host: "destination.example.com",
Port: 22,
Path: "/destination",
SSH: SSH{HostKeyPolicy: HostKeyPolicyAcceptNew},
},
},
{
name: "ssh missing host",
source: Backend{
Backend: BackendSSH,
Port: 22,
Path: "/source",
SSH: SSH{HostKeyPolicy: HostKeyPolicyAcceptNew},
},
destination: Destination{
Backend: BackendSSH,
Port: 22,
Path: "/destination",
SSH: SSH{HostKeyPolicy: HostKeyPolicyAcceptNew},
},
wantErr: true,
},
{
name: "s3 valid",
source: Backend{
Backend: BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "source",
Prefix: "incoming",
Region: DefaultS3Region,
ForcePath: &forcePathStyle,
},
destination: Destination{
Backend: BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "destination",
Prefix: "archive",
Region: DefaultS3Region,
ForcePath: &forcePathStyle,
},
},
{
name: "s3 partial credentials",
source: Backend{
Backend: BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "source",
Region: DefaultS3Region,
Creds: Credentials{AccessKeyIDEnv: "ACCESS_KEY_ID"},
},
destination: Destination{
Backend: BackendS3,
Endpoint: "https://s3.example.com",
Bucket: "destination",
Region: DefaultS3Region,
Creds: Credentials{AccessKeyIDEnv: "ACCESS_KEY_ID"},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sourceErrors := validateBackend(nil, "source", backendViewFromSource(tt.source))
destinationErrors := validateBackend(nil, "destination", backendViewFromDestination(tt.destination))
if got := len(sourceErrors) > 0; got != tt.wantErr {
t.Fatalf("source validation errors = %#v, wantErr %t", sourceErrors, tt.wantErr)
}
if got := len(destinationErrors) > 0; got != tt.wantErr {
t.Fatalf("destination validation errors = %#v, wantErr %t", destinationErrors, tt.wantErr)
}
})
}
}

View File

@@ -1,14 +1,35 @@
package config package config
type Config struct { type Config struct {
Secrets Secrets `yaml:"secrets"` Server Server `yaml:"server"`
Pipelines []Pipeline `yaml:"pipelines"` Secrets Secrets `yaml:"secrets"`
UploadTokens []UploadToken `yaml:"upload_tokens"`
Pipelines []Pipeline `yaml:"pipelines"`
}
type Server struct {
HTTP HTTPServer `yaml:"http"`
}
type HTTPServer struct {
Bind string `yaml:"bind"`
StagingRoot string `yaml:"staging_root"`
MaxUploadSize *ByteSize `yaml:"max_upload_size"`
QueueSize int `yaml:"queue_size"`
MaxConcurrency int `yaml:"max_concurrency"`
Retention *Duration `yaml:"retention"`
} }
type Secrets struct { type Secrets struct {
Directory string `yaml:"directory"` Directory string `yaml:"directory"`
} }
type UploadToken struct {
ID string `yaml:"id"`
TokenEnv string `yaml:"token_env"`
AllowPipelines []string `yaml:"allow_pipelines"`
}
type Pipeline struct { type Pipeline struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Source Backend `yaml:"source"` Source Backend `yaml:"source"`
@@ -50,6 +71,12 @@ type Backend struct {
ForcePath *bool `yaml:"force_path_style"` ForcePath *bool `yaml:"force_path_style"`
Creds Credentials `yaml:"credentials"` Creds Credentials `yaml:"credentials"`
SSH SSH `yaml:",inline"` SSH SSH `yaml:",inline"`
Upload HTTPUpload `yaml:",inline"`
}
type HTTPUpload struct {
StagingPath string `yaml:"staging_path"`
MaxUploadSize *ByteSize `yaml:"max_upload_size"`
} }
type SSH struct { type SSH struct {

View File

@@ -1,13 +1,19 @@
package config package config
import "gitea.maximumdirect.net/eric/distributor/internal/transform" import (
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/transform"
)
const DefaultConfigPath = "/usr/local/etc/distributor/config.yml" const DefaultConfigPath = "/usr/local/etc/distributor/config.yml"
const ( const (
BackendLocal = "local" BackendLocal = "local"
BackendSSH = "ssh" BackendSSH = "ssh"
BackendS3 = "s3" BackendS3 = "s3"
BackendHTTPUpload = "http_upload"
) )
const ( const (
@@ -38,10 +44,23 @@ const (
const DefaultS3Region = "us-east-1" const DefaultS3Region = "us-east-1"
const (
DefaultHTTPBind = "127.0.0.1:8080"
DefaultHTTPStagingRoot = "/var/spool/distributor"
DefaultHTTPMaxUploadSize = ByteSize(20 * 1024 * 1024)
DefaultHTTPQueueSize = 16
DefaultHTTPMaxConcurrency = 1
DefaultHTTPRetention = Duration(24 * time.Hour)
)
func ApplyDefaults(cfg *Config) { func ApplyDefaults(cfg *Config) {
applyHTTPServerDefaults(&cfg.Server.HTTP)
for pipelineIndex := range cfg.Pipelines { for pipelineIndex := range cfg.Pipelines {
pipeline := &cfg.Pipelines[pipelineIndex] pipeline := &cfg.Pipelines[pipelineIndex]
applyBackendDefaults(&pipeline.Source) applyBackendDefaults(&pipeline.Source)
if pipeline.Source.Backend == BackendHTTPUpload {
applyHTTPUploadDefaults(&pipeline.Source.Upload, pipeline.ID, cfg.Server.HTTP)
}
if pipeline.Validation.OnDigestMismatch == "" { if pipeline.Validation.OnDigestMismatch == "" {
pipeline.Validation.OnDigestMismatch = ValidationActionFail pipeline.Validation.OnDigestMismatch = ValidationActionFail
} }
@@ -76,31 +95,63 @@ func ApplyDefaults(cfg *Config) {
} }
} }
func applyBackendDefaults(backend *Backend) { func applyHTTPServerDefaults(server *HTTPServer) {
if backend.Backend == BackendSSH { if server.Bind == "" {
if backend.Port == 0 { server.Bind = DefaultHTTPBind
backend.Port = 22
}
if backend.SSH.HostKeyPolicy == "" {
backend.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
}
} }
if backend.Backend == BackendS3 { if server.StagingRoot == "" {
applyS3Defaults(&backend.Region, &backend.Prefix, &backend.ForcePath) server.StagingRoot = DefaultHTTPStagingRoot
}
if server.MaxUploadSize == nil {
server.MaxUploadSize = byteSize(DefaultHTTPMaxUploadSize)
}
if server.QueueSize == 0 {
server.QueueSize = DefaultHTTPQueueSize
}
if server.MaxConcurrency == 0 {
server.MaxConcurrency = DefaultHTTPMaxConcurrency
}
if server.Retention == nil {
server.Retention = duration(DefaultHTTPRetention)
} }
} }
func applyHTTPUploadDefaults(upload *HTTPUpload, pipelineID string, server HTTPServer) {
if upload.StagingPath == "" && pipelineID != "" {
upload.StagingPath = filepath.Join(server.StagingRoot, pipelineID)
}
if upload.MaxUploadSize == nil && server.MaxUploadSize != nil {
upload.MaxUploadSize = byteSize(*server.MaxUploadSize)
}
}
func byteSize(value ByteSize) *ByteSize {
return &value
}
func duration(value Duration) *Duration {
return &value
}
func applyBackendDefaults(backend *Backend) {
applyStorageBackendDefaults(backend.Backend, &backend.Port, &backend.SSH, &backend.Region, &backend.Prefix, &backend.ForcePath)
}
func applyDestinationDefaults(destination *Destination) { func applyDestinationDefaults(destination *Destination) {
if destination.Backend == BackendSSH { applyStorageBackendDefaults(destination.Backend, &destination.Port, &destination.SSH, &destination.Region, &destination.Prefix, &destination.ForcePath)
if destination.Port == 0 { }
destination.Port = 22
func applyStorageBackendDefaults(backend string, port *int, ssh *SSH, region, prefix *string, forcePath **bool) {
if backend == BackendSSH {
if *port == 0 {
*port = 22
} }
if destination.SSH.HostKeyPolicy == "" { if ssh.HostKeyPolicy == "" {
destination.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew ssh.HostKeyPolicy = HostKeyPolicyAcceptNew
} }
} }
if destination.Backend == BackendS3 { if backend == BackendS3 {
applyS3Defaults(&destination.Region, &destination.Prefix, &destination.ForcePath) applyS3Defaults(region, prefix, forcePath)
} }
} }

View File

@@ -208,6 +208,242 @@ pipelines:
} }
} }
func TestLoadFileDefaultsHTTPServerConfig(t *testing.T) {
cfg := loadConfig(t, `
pipelines:
- id: reports
source:
backend: local
path: /source
destinations:
- id: archive
backend: local
path: /archive
`)
server := cfg.Server.HTTP
if got, want := server.Bind, DefaultHTTPBind; got != want {
t.Fatalf("server.http.bind = %q, want %q", got, want)
}
if got, want := server.StagingRoot, DefaultHTTPStagingRoot; got != want {
t.Fatalf("server.http.staging_root = %q, want %q", got, want)
}
if got, want := *server.MaxUploadSize, DefaultHTTPMaxUploadSize; got != want {
t.Fatalf("server.http.max_upload_size = %s, want %s", got, want)
}
if got, want := server.QueueSize, DefaultHTTPQueueSize; got != want {
t.Fatalf("server.http.queue_size = %d, want %d", got, want)
}
if got, want := server.MaxConcurrency, DefaultHTTPMaxConcurrency; got != want {
t.Fatalf("server.http.max_concurrency = %d, want %d", got, want)
}
if got, want := *server.Retention, DefaultHTTPRetention; got != want {
t.Fatalf("server.http.retention = %s, want %s", got, want)
}
}
func TestLoadFileAcceptsHTTPUploadSourceConfig(t *testing.T) {
cfg := loadConfig(t, `
server:
http:
bind: 127.0.0.1:9090
staging_root: /srv/distributor/staging
max_upload_size: 64MB
queue_size: 32
max_concurrency: 2
retention: 48h
pipelines:
- id: weather-daily
source:
backend: http_upload
staging_path: /srv/distributor/staging/weather-daily
max_upload_size: 32MB
destinations:
- id: archive
backend: local
path: /archive
upload_tokens:
- id: weather-reporter
token_env: WEATHER_DAILY_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
`)
server := cfg.Server.HTTP
if got, want := server.Bind, "127.0.0.1:9090"; got != want {
t.Fatalf("server.http.bind = %q, want %q", got, want)
}
if got, want := server.StagingRoot, "/srv/distributor/staging"; got != want {
t.Fatalf("server.http.staging_root = %q, want %q", got, want)
}
if got, want := *server.MaxUploadSize, ByteSize(64*1024*1024); got != want {
t.Fatalf("server.http.max_upload_size = %s, want %s", got, want)
}
if got, want := server.QueueSize, 32; got != want {
t.Fatalf("server.http.queue_size = %d, want %d", got, want)
}
if got, want := server.MaxConcurrency, 2; got != want {
t.Fatalf("server.http.max_concurrency = %d, want %d", got, want)
}
if got, want := server.Retention.String(), "48h0m0s"; got != want {
t.Fatalf("server.http.retention = %s, want %s", got, want)
}
source := cfg.Pipelines[0].Source
if got, want := source.Backend, BackendHTTPUpload; got != want {
t.Fatalf("source.backend = %q, want %q", got, want)
}
if got, want := source.Upload.StagingPath, "/srv/distributor/staging/weather-daily"; got != want {
t.Fatalf("source.staging_path = %q, want %q", got, want)
}
if got, want := *source.Upload.MaxUploadSize, ByteSize(32*1024*1024); got != want {
t.Fatalf("source.max_upload_size = %s, want %s", got, want)
}
}
func TestLoadFileDefaultsHTTPUploadSourceConfig(t *testing.T) {
cfg := loadConfig(t, `
server:
http:
max_upload_size: 12MB
pipelines:
- id: weather-daily
source:
backend: http_upload
destinations:
- id: archive
backend: local
path: /archive
upload_tokens:
- id: weather-reporter
token_env: WEATHER_DAILY_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
`)
source := cfg.Pipelines[0].Source
if got, want := source.Upload.StagingPath, "/var/spool/distributor/weather-daily"; got != want {
t.Fatalf("source.staging_path = %q, want %q", got, want)
}
if got, want := *source.Upload.MaxUploadSize, ByteSize(12*1024*1024); got != want {
t.Fatalf("source.max_upload_size = %s, want %s", got, want)
}
}
func TestLoadFileAcceptsHTTPUploadTokens(t *testing.T) {
tests := map[string]string{
"valid multi pipeline token": `
pipelines:
- id: weather-daily
source:
backend: http_upload
destinations:
- id: archive
backend: local
path: /archive/weather
- id: calendar-daily
source:
backend: http_upload
destinations:
- id: archive
backend: local
path: /archive/calendar
upload_tokens:
- id: reporter
token_env: REPORTER_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
- calendar-daily
`,
"multiple tokens for one pipeline": `
pipelines:
- id: reports
source:
backend: http_upload
destinations:
- id: archive
backend: local
path: /archive
upload_tokens:
- id: reporter-a
token_env: REPORTER_A_UPLOAD_TOKEN
allow_pipelines:
- reports
- id: reporter-b
token_env: REPORTER_B_UPLOAD_TOKEN
allow_pipelines:
- reports
`,
}
for name, body := range tests {
t.Run(name, func(t *testing.T) {
loadConfig(t, body)
})
}
}
func TestLoadFileRejectsInvalidUploadTokens(t *testing.T) {
tests := map[string]struct {
body string
want string
}{
"missing token list": {
body: `pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "upload_tokens is required",
},
"duplicate token ids": {
body: `upload_tokens: [{id: reporter, token_env: ONE_UPLOAD_TOKEN, allow_pipelines: [reports]}, {id: reporter, token_env: TWO_UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "upload token id reporter is duplicated",
},
"duplicate allowlist entries": {
body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports, reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "allow_pipelines contains duplicate pipeline id reports",
},
"unknown allowed pipeline id": {
body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [missing]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "references unknown pipeline missing",
},
"non upload allowed pipeline id": {
body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}, {id: uploader, token_env: OTHER_UPLOAD_TOKEN, allow_pipelines: [upload]}]
pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive}]}, {id: upload, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive-upload}]}]`,
want: "references non-http_upload pipeline reports",
},
"upload pipeline not allowed": {
body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}, {id: other, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive-other}]}]`,
want: "http_upload pipeline other is not allowed by any upload token",
},
"missing token id": {
body: `upload_tokens: [{token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "upload_tokens[0].id is required",
},
"invalid token id": {
body: `upload_tokens: [{id: ".reporter", token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "upload_tokens[0].id must be a slug-like identifier",
},
"missing token env": {
body: `upload_tokens: [{id: reporter, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "upload_tokens[0].token_env is required",
},
"missing allowlist": {
body: `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
want: "upload_tokens[0].allow_pipelines is required",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assertLoadError(t, tt.body, tt.want)
})
}
}
func TestLoadFileValidBackendConfigs(t *testing.T) { func TestLoadFileValidBackendConfigs(t *testing.T) {
tests := map[string]string{ tests := map[string]string{
"local": ` "local": `
@@ -396,6 +632,32 @@ func TestLoadFileRejectsInvalidS3Config(t *testing.T) {
} }
} }
func TestLoadFileRejectsInvalidHTTPUploadConfig(t *testing.T) {
tests := map[string]string{
"server size": `server: {http: {max_upload_size: 20XB}}`,
"source size": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload, max_upload_size: 20XB}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"zero source size": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload, max_upload_size: 0B}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"server duration": `server: {http: {retention: forever}}`,
"zero server duration": `server: {http: {retention: 0s}}`,
"missing upload tokens": `pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"destination http upload": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: ingest, backend: http_upload}]}]`,
"literal token": `upload_tokens: [{id: reporter, token: secret, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"unknown server field": `server: {http: {surprise: true}}`,
"legacy source token env": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload, token_env: UPLOAD_TOKEN}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"unknown source field": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
pipelines: [{id: reports, source: {backend: http_upload, surprise: true}, 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) { func TestLoadFileDefaultsSSHConfig(t *testing.T) {
cfg := loadConfig(t, ` cfg := loadConfig(t, `
pipelines: pipelines:
@@ -581,6 +843,7 @@ func TestExampleConfigsLoad(t *testing.T) {
"../../examples/local-index.yml", "../../examples/local-index.yml",
"../../examples/fan-out.yml", "../../examples/fan-out.yml",
"../../examples/archive-and-latest.yml", "../../examples/archive-and-latest.yml",
"../../examples/http-upload-local.yml",
"../../examples/ssh-destination.yml", "../../examples/ssh-destination.yml",
"../../examples/s3-destination.yml", "../../examples/s3-destination.yml",
} { } {

117
internal/config/quantity.go Normal file
View File

@@ -0,0 +1,117 @@
package config
import (
"fmt"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type ByteSize int64
type Duration time.Duration
func (size *ByteSize) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.ScalarNode || value.Tag != "!!str" {
return fmt.Errorf("size must be a string with B, KB, MB, or GB suffix")
}
var raw string
if err := value.Decode(&raw); err != nil {
return err
}
parsed, err := ParseByteSize(raw)
if err != nil {
return err
}
*size = parsed
return nil
}
func (size ByteSize) String() string {
value := int64(size)
if value == 0 {
return "0B"
}
units := []struct {
suffix string
multiplier int64
}{
{suffix: "GB", multiplier: 1024 * 1024 * 1024},
{suffix: "MB", multiplier: 1024 * 1024},
{suffix: "KB", multiplier: 1024},
{suffix: "B", multiplier: 1},
}
for _, unit := range units {
if value%unit.multiplier == 0 {
return strconv.FormatInt(value/unit.multiplier, 10) + unit.suffix
}
}
return strconv.FormatInt(value, 10) + "B"
}
func ParseByteSize(raw string) (ByteSize, error) {
value := strings.TrimSpace(raw)
if value == "" {
return 0, fmt.Errorf("size is required")
}
units := []struct {
suffix string
multiplier int64
}{
{suffix: "GB", multiplier: 1024 * 1024 * 1024},
{suffix: "MB", multiplier: 1024 * 1024},
{suffix: "KB", multiplier: 1024},
{suffix: "B", multiplier: 1},
}
for _, unit := range units {
number, ok := strings.CutSuffix(value, unit.suffix)
if !ok {
continue
}
if strings.TrimSpace(number) != number || number == "" {
return 0, fmt.Errorf("size must be an integer followed by B, KB, MB, or GB")
}
parsed, err := strconv.ParseInt(number, 10, 64)
if err != nil {
return 0, fmt.Errorf("size must be an integer followed by B, KB, MB, or GB")
}
if parsed < 0 {
return 0, fmt.Errorf("size must be non-negative")
}
const maxInt64 = int64(1<<63 - 1)
if parsed > 0 && parsed > maxInt64/unit.multiplier {
return 0, fmt.Errorf("size is too large")
}
return ByteSize(parsed * unit.multiplier), nil
}
return 0, fmt.Errorf("size must use B, KB, MB, or GB suffix")
}
func (duration *Duration) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.ScalarNode || value.Tag != "!!str" {
return fmt.Errorf("duration must be a string duration")
}
var raw string
if err := value.Decode(&raw); err != nil {
return err
}
parsed, err := time.ParseDuration(raw)
if err != nil {
return fmt.Errorf("duration must be a valid duration: %w", err)
}
*duration = Duration(parsed)
return nil
}
func (duration Duration) String() string {
return time.Duration(duration).String()
}
func (duration Duration) AsDuration() time.Duration {
return time.Duration(duration)
}

View File

@@ -10,6 +10,10 @@ import (
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
func IsSlugLikeID(value string) bool {
return idPattern.MatchString(value)
}
type ValidationErrors []string type ValidationErrors []string
func (e ValidationErrors) Error() string { func (e ValidationErrors) Error() string {
@@ -22,16 +26,19 @@ func (e ValidationErrors) Error() string {
func Validate(cfg Config) error { func Validate(cfg Config) error {
var errs ValidationErrors var errs ValidationErrors
errs = validateHTTPServer(errs, "server.http", cfg.Server.HTTP)
if len(cfg.Pipelines) == 0 { if len(cfg.Pipelines) == 0 {
errs = append(errs, "pipelines is required") errs = append(errs, "pipelines is required")
} }
pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines)) pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines))
uploadPipelineIDs := make(map[string]struct{})
for pipelineIndex, pipeline := range cfg.Pipelines { for pipelineIndex, pipeline := range cfg.Pipelines {
pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex) pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex)
if pipeline.ID == "" { if pipeline.ID == "" {
errs = append(errs, pipelineContext+".id is required") errs = append(errs, pipelineContext+".id is required")
} else if !idPattern.MatchString(pipeline.ID) { } else if !IsSlugLikeID(pipeline.ID) {
errs = append(errs, pipelineContext+".id must be a slug-like identifier") errs = append(errs, pipelineContext+".id must be a slug-like identifier")
} else if _, exists := pipelineIDs[pipeline.ID]; exists { } else if _, exists := pipelineIDs[pipeline.ID]; exists {
errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated") errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated")
@@ -40,6 +47,9 @@ func Validate(cfg Config) error {
} }
errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source) errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source)
if pipeline.Source.Backend == BackendHTTPUpload && pipeline.ID != "" {
uploadPipelineIDs[pipeline.ID] = struct{}{}
}
errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation) errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation)
if len(pipeline.Destinations) == 0 { if len(pipeline.Destinations) == 0 {
errs = append(errs, pipelineContext+".destinations is required") errs = append(errs, pipelineContext+".destinations is required")
@@ -50,7 +60,7 @@ func Validate(cfg Config) error {
destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex) destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex)
if destination.ID == "" { if destination.ID == "" {
errs = append(errs, destinationContext+".id is required") errs = append(errs, destinationContext+".id is required")
} else if !idPattern.MatchString(destination.ID) { } else if !IsSlugLikeID(destination.ID) {
errs = append(errs, destinationContext+".id must be a slug-like identifier") errs = append(errs, destinationContext+".id must be a slug-like identifier")
} else if _, exists := destinationIDs[destination.ID]; exists { } else if _, exists := destinationIDs[destination.ID]; exists {
errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID) errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID)
@@ -66,61 +76,166 @@ func Validate(cfg Config) error {
} }
} }
errs = validateUploadTokens(errs, cfg.UploadTokens, pipelineIDs, uploadPipelineIDs)
if len(errs) > 0 { if len(errs) > 0 {
return errs return errs
} }
return nil return nil
} }
func validateHTTPServer(errs ValidationErrors, context string, server HTTPServer) ValidationErrors {
if server.Bind == "" {
errs = append(errs, context+".bind is required")
}
if server.StagingRoot == "" {
errs = append(errs, context+".staging_root is required")
}
if server.MaxUploadSize == nil || *server.MaxUploadSize <= 0 {
errs = append(errs, context+".max_upload_size must be greater than zero")
}
if server.QueueSize <= 0 {
errs = append(errs, context+".queue_size must be greater than zero")
}
if server.MaxConcurrency <= 0 {
errs = append(errs, context+".max_concurrency must be greater than zero")
}
if server.Retention == nil || *server.Retention <= 0 {
errs = append(errs, context+".retention must be greater than zero")
}
return errs
}
func validateSourceBackend(errs ValidationErrors, context string, backend Backend) ValidationErrors { 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) if backend.Backend == BackendHTTPUpload {
return validateHTTPUploadSource(errs, context, backend.Upload)
}
return validateBackend(errs, context, backendViewFromSource(backend))
} }
func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors { 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) if destination.Backend == BackendHTTPUpload {
errs = append(errs, context+".backend "+BackendHTTPUpload+" is only supported for sources")
return errs
}
return validateBackend(errs, context, backendViewFromDestination(destination))
} }
func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, endpoint, bucket, prefix string, hostKeyPolicy HostKeyPolicy, creds Credentials) ValidationErrors { func validateHTTPUploadSource(errs ValidationErrors, context string, upload HTTPUpload) ValidationErrors {
switch backend { if upload.StagingPath == "" {
errs = append(errs, context+".staging_path is required for http_upload backend")
}
if upload.MaxUploadSize == nil || *upload.MaxUploadSize <= 0 {
errs = append(errs, context+".max_upload_size must be greater than zero")
}
return errs
}
func validateUploadTokens(errs ValidationErrors, tokens []UploadToken, pipelineIDs, uploadPipelineIDs map[string]struct{}) ValidationErrors {
if len(uploadPipelineIDs) == 0 {
if len(tokens) > 0 {
errs = append(errs, "upload_tokens must reference configured http_upload pipelines")
}
return errs
}
if len(tokens) == 0 {
return append(errs, "upload_tokens is required when any pipeline source backend is http_upload")
}
tokenIDs := make(map[string]struct{}, len(tokens))
allowedUploadPipelineIDs := make(map[string]struct{}, len(uploadPipelineIDs))
for tokenIndex, token := range tokens {
context := fmt.Sprintf("upload_tokens[%d]", tokenIndex)
if token.ID == "" {
errs = append(errs, context+".id is required")
} else if !IsSlugLikeID(token.ID) {
errs = append(errs, context+".id must be a slug-like identifier")
} else if _, exists := tokenIDs[token.ID]; exists {
errs = append(errs, "upload token id "+token.ID+" is duplicated")
} else {
tokenIDs[token.ID] = struct{}{}
}
if token.TokenEnv == "" {
errs = append(errs, context+".token_env is required")
}
if len(token.AllowPipelines) == 0 {
errs = append(errs, context+".allow_pipelines is required")
}
seenAllowed := make(map[string]struct{}, len(token.AllowPipelines))
for allowIndex, pipelineID := range token.AllowPipelines {
allowContext := fmt.Sprintf("%s.allow_pipelines[%d]", context, allowIndex)
if pipelineID == "" {
errs = append(errs, allowContext+" is required")
continue
}
if _, exists := seenAllowed[pipelineID]; exists {
errs = append(errs, context+".allow_pipelines contains duplicate pipeline id "+pipelineID)
continue
}
seenAllowed[pipelineID] = struct{}{}
if _, exists := pipelineIDs[pipelineID]; !exists {
errs = append(errs, allowContext+" references unknown pipeline "+pipelineID)
continue
}
if _, exists := uploadPipelineIDs[pipelineID]; !exists {
errs = append(errs, allowContext+" references non-http_upload pipeline "+pipelineID)
continue
}
allowedUploadPipelineIDs[pipelineID] = struct{}{}
}
}
for pipelineID := range uploadPipelineIDs {
if _, exists := allowedUploadPipelineIDs[pipelineID]; !exists {
errs = append(errs, "http_upload pipeline "+pipelineID+" is not allowed by any upload token")
}
}
return errs
}
func validateBackend(errs ValidationErrors, context string, backend backendView) ValidationErrors {
switch backend.Backend {
case "": case "":
errs = append(errs, context+".backend is required") errs = append(errs, context+".backend is required")
case BackendLocal: case BackendLocal:
if path == "" { if backend.Path == "" {
errs = append(errs, context+".path is required for local backend") errs = append(errs, context+".path is required for local backend")
} }
case BackendSSH: case BackendSSH:
if host == "" { if backend.Host == "" {
errs = append(errs, context+".host is required for ssh backend") errs = append(errs, context+".host is required for ssh backend")
} }
if path == "" { if backend.Path == "" {
errs = append(errs, context+".path is required for ssh backend") errs = append(errs, context+".path is required for ssh backend")
} }
if port < 0 || port > 65535 { if backend.Port < 0 || backend.Port > 65535 {
errs = append(errs, context+".port must be between 1 and 65535") errs = append(errs, context+".port must be between 1 and 65535")
} }
if port == 0 { if backend.Port == 0 {
errs = append(errs, context+".port is required for ssh backend after defaults are applied") errs = append(errs, context+".port is required for ssh backend after defaults are applied")
} }
if hostKeyPolicy != "" { if backend.SSH.HostKeyPolicy != "" {
if _, ok := NormalizeHostKeyPolicy(string(hostKeyPolicy)); !ok { if _, ok := NormalizeHostKeyPolicy(string(backend.SSH.HostKeyPolicy)); !ok {
errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false") errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false")
} }
} }
case BackendS3: case BackendS3:
if endpoint == "" { if backend.Endpoint == "" {
errs = append(errs, context+".endpoint is required for s3 backend") errs = append(errs, context+".endpoint is required for s3 backend")
} }
if bucket == "" { if backend.Bucket == "" {
errs = append(errs, context+".bucket is required for s3 backend") errs = append(errs, context+".bucket is required for s3 backend")
} }
if err := ValidateS3Prefix(prefix); err != nil { if err := ValidateS3Prefix(backend.Prefix); err != nil {
errs = append(errs, context+".prefix must be a clean relative slash-separated path") errs = append(errs, context+".prefix must be a clean relative slash-separated path")
} }
if (creds.AccessKeyIDEnv == "") != (creds.SecretAccessKeyEnv == "") { if (backend.Creds.AccessKeyIDEnv == "") != (backend.Creds.SecretAccessKeyEnv == "") {
errs = append(errs, context+".credentials.access_key_id_env and credentials.secret_access_key_env must be configured together") errs = append(errs, context+".credentials.access_key_id_env and credentials.secret_access_key_env must be configured together")
} }
default: default:
errs = append(errs, context+".backend "+backend+" is unsupported") errs = append(errs, context+".backend "+backend.Backend+" is unsupported")
} }
return errs return errs
} }

351
internal/ingest/archive.go Normal file
View File

@@ -0,0 +1,351 @@
package ingest
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"mime"
"os"
"path"
"path/filepath"
"strings"
sourcebundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
const (
ContentTypeTar = "application/x-tar"
ContentTypeGzip = "application/gzip"
ContentTypeXGzip = "application/x-gzip"
)
var (
ErrUnsupportedContentType = errors.New("unsupported archive content type")
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
ErrExtractedTooLarge = errors.New("extracted bundle exceeds maximum size")
ErrTooManyFiles = errors.New("extracted bundle has too many files")
ErrUnsafeArchivePath = errors.New("unsafe archive path")
)
type StageOptions struct {
Body io.Reader
ContentType string
PipelineStagingPath string
RunID string
MaxUploadSize int64
MaxExtractedSize int64
MaxFileCount int
}
type StagedBundle struct {
Root string
Manifest sourcebundle.Manifest
}
func StageArchive(ctx context.Context, opts StageOptions) (StagedBundle, error) {
if ctx == nil {
ctx = context.Background()
}
if err := validateStageOptions(opts); err != nil {
return StagedBundle{}, err
}
format, err := archiveFormat(opts.ContentType)
if err != nil {
return StagedBundle{}, err
}
if err := os.MkdirAll(opts.PipelineStagingPath, 0o755); err != nil {
return StagedBundle{}, fmt.Errorf("create pipeline staging path: %w", err)
}
tempDir, err := os.MkdirTemp(opts.PipelineStagingPath, "."+opts.RunID+"-")
if err != nil {
return StagedBundle{}, fmt.Errorf("create staging temp dir: %w", err)
}
cleanupTemp := true
defer func() {
if cleanupTemp {
_ = os.RemoveAll(tempDir)
}
}()
archivePath := filepath.Join(tempDir, "upload.archive")
if err := writeLimited(ctx, archivePath, opts.Body, opts.MaxUploadSize); err != nil {
return StagedBundle{}, err
}
extractRoot := filepath.Join(tempDir, "bundle")
if err := os.Mkdir(extractRoot, 0o755); err != nil {
return StagedBundle{}, fmt.Errorf("create extraction root: %w", err)
}
if err := extractArchive(ctx, archivePath, extractRoot, format, opts.MaxExtractedSize, opts.MaxFileCount); err != nil {
return StagedBundle{}, err
}
manifest, err := sourcebundle.LoadManifest(extractRoot)
if err != nil {
return StagedBundle{}, err
}
if err := sourcebundle.ValidateBundle(extractRoot, manifest); err != nil {
return StagedBundle{}, err
}
finalRoot := filepath.Join(opts.PipelineStagingPath, opts.RunID)
if err := os.Rename(extractRoot, finalRoot); err != nil {
return StagedBundle{}, fmt.Errorf("commit staged bundle: %w", err)
}
cleanupTemp = false
if err := os.RemoveAll(tempDir); err != nil {
return StagedBundle{}, fmt.Errorf("remove staging temp dir: %w", err)
}
return StagedBundle{
Root: finalRoot,
Manifest: manifest,
}, nil
}
func validateStageOptions(opts StageOptions) error {
if opts.Body == nil {
return fmt.Errorf("body is required")
}
if opts.PipelineStagingPath == "" {
return fmt.Errorf("pipeline staging path is required")
}
if err := validateRunID(opts.RunID); err != nil {
return err
}
if opts.MaxUploadSize <= 0 {
return fmt.Errorf("max upload size must be greater than zero")
}
if opts.MaxExtractedSize <= 0 {
return fmt.Errorf("max extracted size must be greater than zero")
}
if opts.MaxFileCount <= 0 {
return fmt.Errorf("max file count must be greater than zero")
}
return nil
}
func validateRunID(value string) error {
if value == "" {
return fmt.Errorf("run id is required")
}
if value == "." || value == ".." || strings.ContainsAny(value, `/\`) {
return fmt.Errorf("run id must be a single filesystem path segment")
}
return nil
}
func ValidateContentType(contentType string) error {
_, err := archiveFormat(contentType)
return err
}
type archiveKind int
const (
archiveKindTar archiveKind = iota + 1
archiveKindGzip
)
func archiveFormat(contentType string) (archiveKind, error) {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = contentType
}
switch mediaType {
case ContentTypeTar:
return archiveKindTar, nil
case ContentTypeGzip, ContentTypeXGzip:
return archiveKindGzip, nil
default:
return 0, ErrUnsupportedContentType
}
}
func writeLimited(ctx context.Context, destination string, body io.Reader, maxSize int64) error {
file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create upload archive: %w", err)
}
defer file.Close()
limited := &limitedReader{ctx: ctx, reader: body, limit: maxSize}
if _, err := io.Copy(file, limited); err != nil {
return err
}
if err := file.Close(); err != nil {
return fmt.Errorf("write upload archive: %w", err)
}
return nil
}
type limitedReader struct {
ctx context.Context
reader io.Reader
limit int64
read int64
}
func (r *limitedReader) Read(data []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
if r.read == r.limit {
var probe [1]byte
n, err := r.reader.Read(probe[:])
if n > 0 {
return 0, ErrUploadTooLarge
}
return 0, err
}
remaining := r.limit - r.read
if int64(len(data)) > remaining+1 {
data = data[:remaining+1]
}
n, err := r.reader.Read(data)
if r.read+int64(n) > r.limit {
allowed := int(r.limit - r.read)
r.read = r.limit
return allowed, ErrUploadTooLarge
}
r.read += int64(n)
return n, err
}
func extractArchive(ctx context.Context, archivePath, destination string, format archiveKind, maxExtractedSize int64, maxFileCount int) error {
file, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("open upload archive: %w", err)
}
defer file.Close()
var reader io.Reader = file
var gzipReader *gzip.Reader
if format == archiveKindGzip {
gzipReader, err = gzip.NewReader(file)
if err != nil {
return fmt.Errorf("open gzip archive: %w", err)
}
defer gzipReader.Close()
reader = gzipReader
}
extractor := archiveExtractor{
ctx: ctx,
destination: destination,
maxExtractedSize: maxExtractedSize,
maxFileCount: maxFileCount,
}
if err := extractor.extract(tar.NewReader(reader)); err != nil {
return err
}
if extractor.rootManifestCount != 1 {
return fmt.Errorf("archive must contain exactly one root-level manifest.json")
}
return nil
}
type archiveExtractor struct {
ctx context.Context
destination string
maxExtractedSize int64
maxFileCount int
extractedSize int64
fileCount int
rootManifestCount int
seenFiles map[string]struct{}
}
func (e *archiveExtractor) extract(reader *tar.Reader) error {
e.seenFiles = make(map[string]struct{})
for {
if err := e.ctx.Err(); err != nil {
return err
}
header, err := reader.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read tar archive: %w", err)
}
name, err := cleanArchivePath(header.Name)
if err != nil {
return err
}
if path.Base(name) == sourcebundle.ManifestName {
if name != sourcebundle.ManifestName {
return fmt.Errorf("nested manifest %q is not allowed", name)
}
e.rootManifestCount++
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(filepath.Join(e.destination, filepath.FromSlash(name)), 0o755); err != nil {
return fmt.Errorf("create archive directory %q: %w", name, err)
}
case tar.TypeReg, tar.TypeRegA:
if err := e.extractFile(reader, name, header.Size); err != nil {
return err
}
default:
return fmt.Errorf("archive entry %q has unsupported type %c", name, header.Typeflag)
}
}
}
func (e *archiveExtractor) extractFile(reader *tar.Reader, name string, size int64) error {
if size < 0 {
return fmt.Errorf("archive entry %q has invalid size", name)
}
e.fileCount++
if e.fileCount > e.maxFileCount {
return ErrTooManyFiles
}
e.extractedSize += size
if e.extractedSize > e.maxExtractedSize {
return ErrExtractedTooLarge
}
if _, exists := e.seenFiles[name]; exists {
return fmt.Errorf("archive entry %q is duplicated", name)
}
e.seenFiles[name] = struct{}{}
fullPath := filepath.Join(e.destination, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return fmt.Errorf("create archive parent for %q: %w", name, err)
}
file, err := os.OpenFile(fullPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return fmt.Errorf("create archive file %q: %w", name, err)
}
defer file.Close()
if _, err := io.CopyN(file, reader, size); err != nil {
return fmt.Errorf("extract archive file %q: %w", name, err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("extract archive file %q: %w", name, err)
}
return nil
}
func cleanArchivePath(value string) (string, error) {
if value == "" || strings.Contains(value, `\`) || strings.HasPrefix(value, "/") {
return "", ErrUnsafeArchivePath
}
cleaned := path.Clean(value)
if cleaned != value {
return "", ErrUnsafeArchivePath
}
for _, segment := range strings.Split(value, "/") {
if segment == "" || segment == "." || segment == ".." {
return "", ErrUnsafeArchivePath
}
}
return value, nil
}

View File

@@ -0,0 +1,449 @@
package ingest
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
sourcebundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
func TestStageArchiveAcceptsTar(t *testing.T) {
archive := validArchive(t, false)
staged := stageArchive(t, archive, ContentTypeTar)
if got, want := staged.Manifest.ID, "reports.ingest"; got != want {
t.Fatalf("manifest id = %q, want %q", got, want)
}
if got := readFile(t, staged.Root, "report.md"); got != "# Report\n" {
t.Fatalf("report = %q", got)
}
}
func TestStageArchiveAcceptsGzipTar(t *testing.T) {
archive := validArchive(t, true)
staged := stageArchive(t, archive, ContentTypeGzip+"; charset=binary")
if got, want := staged.Manifest.ID, "reports.ingest"; got != want {
t.Fatalf("manifest id = %q, want %q", got, want)
}
if got := readFile(t, staged.Root, "summary.txt"); got != "Summary\n" {
t.Fatalf("summary = %q", got)
}
}
func TestStageArchiveRejectsUnsupportedContentType(t *testing.T) {
err := stageArchiveError(t, validArchive(t, false), "application/zip", nil)
if !errors.Is(err, ErrUnsupportedContentType) {
t.Fatalf("StageArchive() error = %v, want ErrUnsupportedContentType", err)
}
}
func TestValidateContentType(t *testing.T) {
for _, contentType := range []string{
ContentTypeTar,
ContentTypeGzip,
ContentTypeXGzip,
ContentTypeGzip + "; charset=binary",
} {
t.Run(contentType, func(t *testing.T) {
if err := ValidateContentType(contentType); err != nil {
t.Fatalf("ValidateContentType() error = %v", err)
}
})
}
if err := ValidateContentType("application/zip"); !errors.Is(err, ErrUnsupportedContentType) {
t.Fatalf("ValidateContentType() error = %v, want ErrUnsupportedContentType", err)
}
}
func TestStageArchiveEnforcesMaxUploadSize(t *testing.T) {
archive := validArchive(t, false)
err := stageArchiveError(t, archive, ContentTypeTar, func(opts *StageOptions) {
opts.MaxUploadSize = int64(len(archive) - 1)
})
if !errors.Is(err, ErrUploadTooLarge) {
t.Fatalf("StageArchive() error = %v, want ErrUploadTooLarge", err)
}
}
func TestStageArchiveEnforcesExtractionLimits(t *testing.T) {
archive := validArchive(t, false)
tests := map[string]struct {
mutate func(*StageOptions)
wantErr error
}{
"size": {
mutate: func(opts *StageOptions) {
opts.MaxExtractedSize = 1
},
wantErr: ErrExtractedTooLarge,
},
"files": {
mutate: func(opts *StageOptions) {
opts.MaxFileCount = 1
},
wantErr: ErrTooManyFiles,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
err := stageArchiveError(t, archive, ContentTypeTar, tt.mutate)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("StageArchive() error = %v, want %v", err, tt.wantErr)
}
})
}
}
func TestStageArchiveRejectsUnsafeEntries(t *testing.T) {
tests := map[string][]tarEntry{
"absolute path": {
fileEntry("/report.md", "report"),
},
"path traversal": {
fileEntry("../report.md", "report"),
},
"dot path": {
fileEntry("./report.md", "report"),
},
"dot segment": {
fileEntry("nested/./report.md", "report"),
},
"backslash path": {
fileEntry(`nested\report.md`, "report"),
},
"duplicate file": {
fileEntry("report.md", "report"),
fileEntry("report.md", "report"),
},
"symlink": {
{name: "link.md", typeflag: tar.TypeSymlink, linkname: "report.md"},
},
"hardlink": {
{name: "link.md", typeflag: tar.TypeLink, linkname: "report.md"},
},
"device": {
{name: "device", typeflag: tar.TypeChar},
},
"fifo": {
{name: "socket", typeflag: tar.TypeFifo},
},
"socket": {
{name: "socket", typeflag: 'S'},
},
}
for name, entries := range tests {
t.Run(name, func(t *testing.T) {
err := stageArchiveError(t, makeArchive(t, false, entries...), ContentTypeTar, nil)
if err == nil {
t.Fatal("StageArchive() error = nil, want error")
}
})
}
}
func TestStageArchiveRejectsBundleValidationFailures(t *testing.T) {
tests := map[string][]tarEntry{
"missing manifest": {
fileEntry("report.md", "report"),
},
"nested manifest": {
fileEntry("manifest.json", manifestJSON(t, manifestFor("reports.nested", fileSpec{path: "report.md", body: "report"}))),
fileEntry("nested/manifest.json", "{}"),
fileEntry("report.md", "report"),
},
"listed nested manifest": {
fileEntry("manifest.json", uncheckedManifestJSON(t, manifestFor("reports.listed.nested", fileSpec{path: "nested/manifest.json", body: "{}"}))),
fileEntry("nested/manifest.json", "{}"),
},
"listed state file": {
fileEntry("manifest.json", uncheckedManifestJSON(t, manifestFor("reports.listed.state", fileSpec{path: ".distributor.json", body: "{}"}))),
fileEntry(".distributor.json", "{}"),
},
"missing listed file": {
fileEntry("manifest.json", manifestJSON(t, manifestFor("reports.missing", fileSpec{path: "missing.md", body: "missing"}))),
},
"digest mismatch": {
fileEntry("manifest.json", manifestJSON(t, manifestFor("reports.digest", fileSpec{path: "report.md", body: "expected"}))),
fileEntry("report.md", "actual"),
},
"non regular listed file": {
fileEntry("manifest.json", manifestJSON(t, manifestFor("reports.directory", fileSpec{path: "report.md", body: "report"}))),
{name: "report.md", typeflag: tar.TypeDir},
},
}
for name, entries := range tests {
t.Run(name, func(t *testing.T) {
err := stageArchiveError(t, makeArchive(t, false, entries...), ContentTypeTar, nil)
if err == nil {
t.Fatal("StageArchive() error = nil, want error")
}
})
}
}
func TestStageArchiveAcceptsSafeDirectories(t *testing.T) {
archive := makeArchive(t, false,
tarEntry{name: "nested", typeflag: tar.TypeDir},
tarEntry{name: "nested/assets", typeflag: tar.TypeDir},
fileEntry("manifest.json", manifestJSON(t, manifestFor("reports.directories", fileSpec{path: "nested/assets/report.md", body: "report"}))),
fileEntry("nested/assets/report.md", "report"),
)
staged := stageArchive(t, archive, ContentTypeTar)
if got := readFile(t, staged.Root, "nested/assets/report.md"); got != "report" {
t.Fatalf("report = %q", got)
}
}
func TestStageArchiveCleansUpFailedExtraction(t *testing.T) {
stagingPath := filepath.Join(t.TempDir(), "staging")
archive := makeArchive(t, false, fileEntry("../report.md", "report"))
_, err := StageArchive(context.Background(), StageOptions{
Body: bytes.NewReader(archive),
ContentType: ContentTypeTar,
PipelineStagingPath: stagingPath,
RunID: "reports.20260603T120000Z.abcd",
MaxUploadSize: int64(len(archive)),
MaxExtractedSize: 1024 * 1024,
MaxFileCount: 10,
})
if err == nil {
t.Fatal("StageArchive() error = nil, want error")
}
entries, err := os.ReadDir(stagingPath)
if err != nil {
t.Fatalf("ReadDir() error = %v", err)
}
if len(entries) != 0 {
t.Fatalf("staging entries = %d, want cleanup", len(entries))
}
}
func stageArchive(t *testing.T, archive []byte, contentType string) StagedBundle {
t.Helper()
staged, err := StageArchive(context.Background(), defaultStageOptions(t, archive, contentType))
if err != nil {
t.Fatalf("StageArchive() error = %v", err)
}
return staged
}
func stageArchiveError(t *testing.T, archive []byte, contentType string, mutate func(*StageOptions)) error {
t.Helper()
opts := defaultStageOptions(t, archive, contentType)
if mutate != nil {
mutate(&opts)
}
_, err := StageArchive(context.Background(), opts)
if err == nil {
t.Fatal("StageArchive() error = nil, want error")
}
return err
}
func defaultStageOptions(t *testing.T, archive []byte, contentType string) StageOptions {
t.Helper()
return StageOptions{
Body: bytes.NewReader(archive),
ContentType: contentType,
PipelineStagingPath: filepath.Join(t.TempDir(), "staging"),
RunID: "reports.20260603T120000Z.abcd",
MaxUploadSize: int64(len(archive)),
MaxExtractedSize: 1024 * 1024,
MaxFileCount: 10,
}
}
func validArchive(t *testing.T, compressed bool) []byte {
t.Helper()
root := filepath.Join(t.TempDir(), "bundle")
sourceRoot := t.TempDir()
writeFile(t, sourceRoot, "report.md", "# Report\n")
writeFile(t, sourceRoot, "summary.txt", "Summary\n")
_, err := sourcebundle.WriteBundle(sourcebundle.WriteBundleOptions{
Root: root,
ID: "reports.ingest",
Created: time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC),
Files: []sourcebundle.BundleFile{
{SourcePath: filepath.Join(sourceRoot, "report.md"), Path: "report.md"},
{SourcePath: filepath.Join(sourceRoot, "summary.txt"), Path: "summary.txt"},
},
})
if err != nil {
t.Fatalf("WriteBundle() error = %v", err)
}
var entries []tarEntry
if err := filepath.WalkDir(root, func(filePath string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
relative, err := filepath.Rel(root, filePath)
if err != nil {
return err
}
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
entries = append(entries, fileEntry(filepath.ToSlash(relative), string(data)))
return nil
}); err != nil {
t.Fatalf("walk bundle: %v", err)
}
return makeArchive(t, compressed, entries...)
}
type tarEntry struct {
name string
typeflag byte
body []byte
linkname string
}
func fileEntry(name, body string) tarEntry {
return tarEntry{name: name, typeflag: tar.TypeReg, body: []byte(body)}
}
func makeArchive(t *testing.T, compressed bool, entries ...tarEntry) []byte {
t.Helper()
var output bytes.Buffer
var writer *tar.Writer
var gzipWriter *gzip.Writer
if compressed {
gzipWriter = gzip.NewWriter(&output)
writer = tar.NewWriter(gzipWriter)
} else {
writer = tar.NewWriter(&output)
}
for _, entry := range entries {
header := &tar.Header{
Name: entry.name,
Typeflag: entry.typeflag,
Size: int64(len(entry.body)),
Mode: 0o644,
Linkname: entry.linkname,
}
if entry.typeflag == tar.TypeDir {
header.Size = 0
header.Mode = 0o755
}
if err := writer.WriteHeader(header); err != nil {
t.Fatalf("WriteHeader(%q) error = %v", entry.name, err)
}
if len(entry.body) > 0 {
if _, err := writer.Write(entry.body); err != nil {
t.Fatalf("Write(%q) error = %v", entry.name, err)
}
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close tar writer: %v", err)
}
if gzipWriter != nil {
if err := gzipWriter.Close(); err != nil {
t.Fatalf("close gzip writer: %v", err)
}
}
return output.Bytes()
}
type fileSpec struct {
path string
body string
}
func manifestFor(id string, files ...fileSpec) sourcebundle.Manifest {
manifest := sourcebundle.Manifest{
SchemaVersion: sourcebundle.SchemaVersion,
ID: id,
Created: time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC),
}
for _, file := range files {
manifest.Files = append(manifest.Files, sourcebundle.ManifestFile{
Path: file.path,
SHA256: sourcebundle.FileDigest([]byte(file.body)),
Size: int64(len(file.body)),
})
}
manifest.Digest = sourcebundle.BundleDigest(manifest.Files)
return manifest
}
func manifestJSON(t *testing.T, manifest sourcebundle.Manifest) string {
t.Helper()
data, err := sourcebundle.MarshalManifest(manifest)
if err != nil {
t.Fatalf("MarshalManifest() error = %v", err)
}
return string(data)
}
func uncheckedManifestJSON(t *testing.T, manifest sourcebundle.Manifest) string {
t.Helper()
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatalf("MarshalIndent() error = %v", err)
}
return string(append(data, '\n'))
}
func writeFile(t *testing.T, root, relative, body string) {
t.Helper()
fullPath := filepath.Join(root, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(fullPath, []byte(body), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
}
func readFile(t *testing.T, root, relative string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(relative)))
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
return string(data)
}
func TestCleanArchivePath(t *testing.T) {
tests := map[string]bool{
"manifest.json": true,
"nested/report.md": true,
"": false,
"/absolute.md": false,
"../escape.md": false,
"nested/../report.md": false,
`nested\report.md`: false,
"./report.md": false,
"nested//report.md": false,
}
for value, wantOK := range tests {
t.Run(strings.ReplaceAll(value, "/", "_"), func(t *testing.T) {
_, err := cleanArchivePath(value)
if wantOK && err != nil {
t.Fatalf("cleanArchivePath(%q) error = %v", value, err)
}
if !wantOK && err == nil {
t.Fatalf("cleanArchivePath(%q) error = nil, want error", value)
}
})
}
}

View File

@@ -12,6 +12,10 @@ func TestValidatePath(t *testing.T) {
"report.md", "report.md",
"daily/report.md", "daily/report.md",
"a-b_1.2/report.html", "a-b_1.2/report.html",
"manifest.json",
StateFileName,
"nested/manifest.json",
"nested/" + StateFileName,
} }
for _, path := range valid { for _, path := range valid {
t.Run("valid "+path, func(t *testing.T) { t.Run("valid "+path, func(t *testing.T) {
@@ -23,10 +27,14 @@ func TestValidatePath(t *testing.T) {
invalid := []string{ invalid := []string{
"", "",
".",
"./report.md",
"/absolute", "/absolute",
"../outside", "../outside",
"nested/../outside", "nested/../outside",
"nested/.",
"nested/./file", "nested/./file",
"nested/",
"nested//file", "nested//file",
`nested\file`, `nested\file`,
} }

View File

@@ -90,17 +90,33 @@ func TestBuildManifestRequiresOneFileMode(t *testing.T) {
} }
} }
func TestBuildManifestRejectsUnsafePath(t *testing.T) { func TestBuildManifestRejectsUnsafeExplicitPaths(t *testing.T) {
root := t.TempDir() root := t.TempDir()
writeFile(t, root, "report.txt", "report") writeFile(t, root, "report.txt", "report")
_, err := BuildManifest(BuildOptions{ tests := []string{
Root: root, "",
ID: "reports.unsafe", "../report.txt",
Files: []string{"../report.txt"}, "/report.txt",
}) "nested/../report.txt",
if err == nil { "nested/./report.txt",
t.Fatal("BuildManifest() error = nil, want unsafe path error") `nested\report.txt`,
ManifestName,
distributorStateName,
"nested/" + ManifestName,
"nested/" + distributorStateName,
}
for _, sourcePath := range tests {
t.Run(sourcePath, func(t *testing.T) {
_, err := BuildManifest(BuildOptions{
Root: root,
ID: "reports.unsafe",
Files: []string{sourcePath},
})
if err == nil {
t.Fatal("BuildManifest() error = nil, want unsafe path error")
}
})
} }
} }
@@ -330,7 +346,21 @@ func TestValidateSourcePath(t *testing.T) {
t.Fatalf("ValidateSourcePath(%q) error = %v", path, err) t.Fatalf("ValidateSourcePath(%q) error = %v", path, err)
} }
} }
invalid := []string{"", "../report.md", "/report.md", "nested/../report.md", `nested\report.md`, ManifestName, distributorStateName} invalid := []string{
"",
".",
"./report.md",
"../report.md",
"/report.md",
"nested/../report.md",
"nested/./report.md",
"nested//report.md",
`nested\report.md`,
ManifestName,
distributorStateName,
"nested/" + ManifestName,
"nested/" + distributorStateName,
}
for _, path := range invalid { for _, path := range invalid {
if err := ValidateSourcePath(path); err == nil { if err := ValidateSourcePath(path); err == nil {
t.Fatalf("ValidateSourcePath(%q) error = nil, want error", path) t.Fatalf("ValidateSourcePath(%q) error = nil, want error", path)

View File

@@ -1,9 +1,104 @@
// Package bundle provides producer-facing helpers for distributor source // Package bundle provides producer-facing helpers for distributor source
// bundle manifests. // bundles.
// //
// A source bundle is a local directory containing a manifest.json file and the // A source bundle is a local directory containing manifest.json and the files
// files listed by that manifest. This package owns the public manifest model, // listed by that manifest. Producer applications use this package when they
// digest calculation, path validation, manifest parsing, manifest building, // need to generate manifests, validate bundles locally, or write complete
// local bundle writing, and local bundle validation used by Go producer // bundle directories for distributor to discover, upload, or publish.
// applications. //
// # Bundle Contract
//
// The source manifest is the producer-to-distributor contract. It is named by
// ManifestName, currently "manifest.json", and uses SchemaVersion, currently 1.
// A Manifest contains:
//
// - SchemaVersion: the source manifest schema version.
// - ID: the producer's stable bundle identifier.
// - Digest: the canonical digest of the ordered file records.
// - Created: an RFC3339 timestamp when marshaled to JSON.
// - Files: an ordered list of ManifestFile records.
//
// Each ManifestFile records a slash-separated bundle-relative Path, a lowercase
// sha256:<64 hex> SHA256 digest, and a byte Size. File order is significant for
// the bundle digest and should be chosen deliberately by the producer. Explicit
// file lists preserve caller order; scan mode sorts by slash-separated path.
//
// # Path Rules
//
// Public bundle paths are always slash-separated and relative to the bundle
// root. ValidateSourcePath rejects empty paths, absolute paths, path traversal,
// dot segments, backslashes, and reserved manifest/state paths. Source files
// must be regular files; symlinks and other special files are rejected.
//
// BuildManifest with Scan true recursively scans Root, includes regular files
// including dotfiles, excludes manifest.json and .distributor.json, rejects
// symlinks, and sorts paths lexically. BuildManifest with Files uses exactly
// the caller-provided paths and preserves their order. Exactly one selection
// mode must be used.
//
// # Manifest Workflows
//
// BuildManifest reads existing files under a local root, calculates each
// ManifestFile, defaults a zero Created value to the current UTC time, calculates
// the bundle digest, and validates the result. WriteManifest writes
// manifest.json and fails if it already exists unless WriteManifestOptions has
// Overwrite set. LoadManifest reads and parses manifest.json. ParseManifest and
// MarshalManifest are useful when an application stores or transmits manifest
// bytes directly; MarshalManifest validates before writing deterministic,
// indented JSON with a trailing newline.
//
// ValidateManifest checks manifest-only semantics, including schema version,
// required fields, path safety, duplicate file paths, digest syntax, file sizes,
// and bundle digest. ValidateBundle checks a supplied Manifest against local
// files under a root, including existence, regular-file type, size, SHA-256
// digest, path safety, and bundle digest.
//
// # Complete Bundle Writing
//
// WriteBundle is the most convenient producer workflow when source files live
// outside the final bundle directory. It copies each BundleFile.SourcePath into
// a staged bundle at BundleFile.Path, builds and writes a compliant manifest,
// validates the staged bundle, and promotes it to WriteBundleOptions.Root.
// Overwrite permits replacement of an existing bundle root using a best-effort
// sibling temporary and backup strategy.
//
// # Digest Helpers
//
// FileDigest returns the sha256:<64 hex> digest for file bytes. BundleDigest
// returns the canonical bundle digest for an ordered []ManifestFile.
// CanonicalFilePayload returns the JSON payload used by BundleDigest, which is
// mainly useful for tests and diagnostics. ValidateDigest checks digest syntax.
//
// Example: build and write a manifest for files already under a bundle root.
//
// root := "/var/lib/reports/daily-2026-06-06"
// manifest, err := bundle.BuildManifest(bundle.BuildOptions{
// Root: root,
// ID: "reports.daily.2026-06-06",
// Files: []string{"report.md", "summary.txt"},
// })
// if err != nil {
// return err
// }
// if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
// return err
// }
// if err := bundle.ValidateBundle(root, manifest); err != nil {
// return err
// }
//
// Example: create a complete bundle from producer-generated files.
//
// manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
// Root: "/var/lib/distributor-source/daily-2026-06-06",
// ID: "reports.daily.2026-06-06",
// Files: []bundle.BundleFile{
// {SourcePath: "/tmp/report.md", Path: "report.md"},
// {SourcePath: "/tmp/summary.txt", Path: "summary.txt"},
// },
// })
// if err != nil {
// return err
// }
// _ = manifest
package bundle package bundle

View File

@@ -23,8 +23,7 @@ func ValidateSourcePath(value string) error {
return fmt.Errorf("source path %q must be a clean relative slash-separated path", value) return fmt.Errorf("source path %q must be a clean relative slash-separated path", value)
} }
} }
switch value { if path.Base(value) == ManifestName || path.Base(value) == distributorStateName {
case ManifestName, distributorStateName:
return fmt.Errorf("%q is reserved", value) return fmt.Errorf("%q is reserved", value)
} }
return nil return nil

104
pkg/upload/archive.go Normal file
View File

@@ -0,0 +1,104 @@
package upload
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
func archiveBundle(root string, manifest bundle.Manifest) ([]byte, error) {
var output bytes.Buffer
gzipWriter := gzip.NewWriter(&output)
tarWriter := tar.NewWriter(gzipWriter)
manifestData, err := bundle.MarshalManifest(manifest)
if err != nil {
return nil, err
}
if err := writeTarEntry(tarWriter, bundle.ManifestName, manifestData, 0o600, manifest.Created); err != nil {
_ = tarWriter.Close()
_ = gzipWriter.Close()
return nil, err
}
for _, manifestFile := range manifest.Files {
fullPath := filepath.Join(root, filepath.FromSlash(manifestFile.Path))
info, err := os.Lstat(fullPath)
if err != nil {
_ = tarWriter.Close()
_ = gzipWriter.Close()
return nil, fmt.Errorf("file %q stat: %w", manifestFile.Path, err)
}
if !info.Mode().IsRegular() {
_ = tarWriter.Close()
_ = gzipWriter.Close()
return nil, fmt.Errorf("file %q must be a regular file", manifestFile.Path)
}
file, err := os.Open(fullPath)
if err != nil {
_ = tarWriter.Close()
_ = gzipWriter.Close()
return nil, fmt.Errorf("file %q open: %w", manifestFile.Path, err)
}
if err := writeTarFile(tarWriter, manifestFile.Path, file, info.Mode().Perm(), info.ModTime(), info.Size()); err != nil {
_ = file.Close()
_ = tarWriter.Close()
_ = gzipWriter.Close()
return nil, err
}
if err := file.Close(); err != nil {
_ = tarWriter.Close()
_ = gzipWriter.Close()
return nil, fmt.Errorf("file %q close: %w", manifestFile.Path, err)
}
}
if err := tarWriter.Close(); err != nil {
_ = gzipWriter.Close()
return nil, fmt.Errorf("close tar archive: %w", err)
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("close gzip archive: %w", err)
}
return output.Bytes(), nil
}
func writeTarEntry(writer *tar.Writer, name string, data []byte, mode int64, modTime time.Time) error {
header := &tar.Header{
Name: name,
Mode: mode,
Size: int64(len(data)),
ModTime: modTime,
}
if err := writer.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header %q: %w", name, err)
}
if _, err := writer.Write(data); err != nil {
return fmt.Errorf("write tar entry %q: %w", name, err)
}
return nil
}
func writeTarFile(writer *tar.Writer, name string, file *os.File, mode os.FileMode, modTime time.Time, size int64) error {
if mode == 0 {
mode = 0o600
}
header := &tar.Header{
Name: name,
Mode: int64(mode),
Size: size,
ModTime: modTime,
}
if err := writer.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header %q: %w", name, err)
}
if _, err := io.Copy(writer, file); err != nil {
return fmt.Errorf("write tar entry %q: %w", name, err)
}
return nil
}

444
pkg/upload/client.go Normal file
View File

@@ -0,0 +1,444 @@
package upload
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
const (
runsPath = "runs"
idempotencyKeyHeader = "Idempotency-Key"
defaultHTTPTimeout = 30 * time.Second
defaultRetryAttempts = 3
defaultRetryBaseDelay = 100 * time.Millisecond
defaultRetryMaxDelay = time.Second
uploadContentTypeGzip = "application/gzip"
authorizationPrefix = "Bearer "
redactedSecret = "[redacted]"
)
var pipelineIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
func NewClient(opts ClientOptions) (*Client, error) {
endpoint, err := cleanEndpoint(opts.Endpoint)
if err != nil {
return nil, err
}
if opts.Token == "" {
return nil, fmt.Errorf("token is required")
}
retry, err := cleanRetryOptions(opts.Retry)
if err != nil {
return nil, err
}
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: defaultHTTPTimeout}
}
return &Client{
endpoint: endpoint,
token: opts.Token,
httpClient: httpClient,
retry: retry,
}, nil
}
func (c *Client) UploadBundle(ctx context.Context, opts UploadBundleOptions) (Result, error) {
if c == nil {
return Result{}, fmt.Errorf("client is nil")
}
if opts.Validate && opts.DisableValidation {
return Result{}, fmt.Errorf("validate and disable validation cannot both be set")
}
if err := validatePipelineID(opts.PipelineID); err != nil {
return Result{}, err
}
if opts.Root == "" {
return Result{}, fmt.Errorf("root is required")
}
manifest, err := bundle.LoadManifest(opts.Root)
if err != nil {
return Result{}, c.redactError(err)
}
if shouldValidateBundle(opts.Validate, opts.DisableValidation) {
if err := bundle.ValidateBundle(opts.Root, manifest); err != nil {
return Result{}, c.redactError(err)
}
}
archive, err := archiveBundle(opts.Root, manifest)
if err != nil {
return Result{}, c.redactError(err)
}
key, err := uploadIdempotencyKey(opts.IdempotencyKey)
if err != nil {
return Result{}, err
}
return c.uploadArchive(ctx, opts.PipelineID, archive, key)
}
func (c *Client) UploadFiles(ctx context.Context, opts UploadFilesOptions) (Result, error) {
if c == nil {
return Result{}, fmt.Errorf("client is nil")
}
if opts.Validate && opts.DisableValidation {
return Result{}, fmt.Errorf("validate and disable validation cannot both be set")
}
if err := validatePipelineID(opts.PipelineID); err != nil {
return Result{}, err
}
if opts.ID == "" {
return Result{}, fmt.Errorf("id is required")
}
if len(opts.Files) == 0 {
return Result{}, fmt.Errorf("files is required")
}
tempRoot, err := os.MkdirTemp(opts.TempDir, "distributor-upload-*")
if err != nil {
return Result{}, c.redactError(fmt.Errorf("create temporary bundle root: %w", err))
}
defer func() {
_ = os.RemoveAll(tempRoot)
}()
localBundleRoot := filepath.Join(tempRoot, "bundle")
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: localBundleRoot,
ID: opts.ID,
Created: opts.Created,
Files: opts.Files,
})
if err != nil {
return Result{}, c.redactError(err)
}
if shouldValidateBundle(opts.Validate, opts.DisableValidation) {
if err := bundle.ValidateBundle(localBundleRoot, manifest); err != nil {
return Result{}, c.redactError(err)
}
}
archive, err := archiveBundle(localBundleRoot, manifest)
if err != nil {
return Result{}, c.redactError(err)
}
key, err := uploadIdempotencyKey(opts.IdempotencyKey)
if err != nil {
return Result{}, err
}
return c.uploadArchive(ctx, opts.PipelineID, archive, key)
}
func (c *Client) Status(ctx context.Context, runID string) (RunStatus, error) {
if c == nil {
return RunStatus{}, fmt.Errorf("client is nil")
}
if runID == "" {
return RunStatus{}, fmt.Errorf("run id is required")
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return RunStatus{}, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.statusURL(runID), nil)
if err != nil {
return RunStatus{}, c.redactError(err)
}
c.authorize(request)
response, err := c.httpClient.Do(request)
if err != nil {
return RunStatus{}, c.redactError(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return RunStatus{}, c.responseError(response)
}
var status RunStatus
if err := json.NewDecoder(response.Body).Decode(&status); err != nil {
return RunStatus{}, c.redactError(fmt.Errorf("decode run status: %w", err))
}
return status, nil
}
func (c *Client) uploadArchive(ctx context.Context, pipelineID string, archive []byte, idempotencyKey string) (Result, error) {
if ctx == nil {
ctx = context.Background()
}
var lastErr error
for attempt := 1; attempt <= c.retry.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return Result{}, err
}
result, retry, err := c.uploadAttempt(ctx, pipelineID, archive, idempotencyKey)
if err == nil {
return result, nil
}
lastErr = err
if !retry || attempt == c.retry.MaxAttempts {
return Result{}, err
}
if err := waitForRetry(ctx, retryDelay(c.retry, attempt)); err != nil {
return Result{}, err
}
}
return Result{}, lastErr
}
func (c *Client) uploadAttempt(ctx context.Context, pipelineID string, archive []byte, idempotencyKey string) (Result, bool, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.uploadURL(pipelineID), bytes.NewReader(archive))
if err != nil {
return Result{}, false, c.redactError(err)
}
c.authorize(request)
request.Header.Set("Content-Type", uploadContentTypeGzip)
request.Header.Set(idempotencyKeyHeader, idempotencyKey)
response, err := c.httpClient.Do(request)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return Result{}, false, ctxErr
}
return Result{}, isRetryableNetworkError(err), c.redactError(err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusAccepted {
var result Result
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return Result{}, false, c.redactError(fmt.Errorf("decode upload response: %w", err))
}
if result.RunID == "" {
return Result{}, false, fmt.Errorf("upload response run_id is required")
}
return result, false, nil
}
err = c.responseError(response)
return Result{}, response.StatusCode == http.StatusServiceUnavailable, err
}
func (c *Client) authorize(request *http.Request) {
request.Header.Set("Authorization", authorizationPrefix+c.token)
}
func (c *Client) uploadURL(pipelineID string) string {
return joinEndpointPath(c.endpoint, "v1", "pipelines", pipelineID, "upload")
}
func (c *Client) statusURL(runID string) string {
return joinEndpointPath(c.endpoint, runsPath, runID)
}
func (c *Client) responseError(response *http.Response) error {
body, readErr := io.ReadAll(response.Body)
message := http.StatusText(response.StatusCode)
retryable := false
if readErr == nil && len(body) > 0 {
var decoded struct {
Error string `json:"error"`
Retryable bool `json:"retryable"`
}
if err := json.Unmarshal(body, &decoded); err == nil && decoded.Error != "" {
message = decoded.Error
retryable = decoded.Retryable
} else if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
message = trimmed
}
}
message = c.redactString(message)
status := c.redactString(response.Status)
httpErr := HTTPError{
StatusCode: response.StatusCode,
Status: status,
Message: message,
Retryable: retryable,
}
if response.StatusCode == http.StatusConflict {
return &IdempotencyConflictError{HTTPError: httpErr}
}
return &httpErr
}
func (c *Client) redactError(err error) error {
if err == nil {
return nil
}
message := c.redactString(err.Error())
if message == err.Error() {
return err
}
return errors.New(message)
}
func (c *Client) redactString(value string) string {
if c == nil || c.token == "" {
return value
}
return strings.ReplaceAll(value, c.token, redactedSecret)
}
func cleanEndpoint(value string) (string, error) {
if value == "" {
return "", fmt.Errorf("endpoint is required")
}
parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("endpoint is invalid: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("endpoint scheme must be http or https")
}
if parsed.Host == "" {
return "", fmt.Errorf("endpoint host is required")
}
if parsed.User != nil {
return "", fmt.Errorf("endpoint userinfo is not supported")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("endpoint must not include query or fragment")
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawPath = ""
return parsed.String(), nil
}
func cleanRetryOptions(opts RetryOptions) (RetryOptions, error) {
if opts.MaxAttempts < 0 {
return RetryOptions{}, fmt.Errorf("retry max attempts must be non-negative")
}
if opts.BaseDelay < 0 {
return RetryOptions{}, fmt.Errorf("retry base delay must be non-negative")
}
if opts.MaxDelay < 0 {
return RetryOptions{}, fmt.Errorf("retry max delay must be non-negative")
}
if opts.MaxAttempts == 0 {
opts.MaxAttempts = defaultRetryAttempts
}
if opts.BaseDelay == 0 {
opts.BaseDelay = defaultRetryBaseDelay
}
if opts.MaxDelay == 0 {
opts.MaxDelay = defaultRetryMaxDelay
}
if opts.MaxDelay < opts.BaseDelay {
return RetryOptions{}, fmt.Errorf("retry max delay must be greater than or equal to base delay")
}
return opts, nil
}
func uploadIdempotencyKey(value string) (string, error) {
if value == "" {
return randomIdempotencyKey()
}
if err := validateIdempotencyKey(value); err != nil {
return "", err
}
return value, nil
}
func validatePipelineID(value string) error {
if value == "" {
return fmt.Errorf("pipeline id is required")
}
if !pipelineIDPattern.MatchString(value) {
return fmt.Errorf("pipeline id must be a slug-like identifier")
}
return nil
}
func validateIdempotencyKey(value string) error {
if value == "" {
return fmt.Errorf("idempotency key is required")
}
if len(value) > 128 {
return fmt.Errorf("idempotency key must be at most 128 bytes")
}
for index := 0; index < len(value); index++ {
character := value[index]
if character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' ||
character == '.' ||
character == '_' ||
character == '-' ||
character == ':' {
continue
}
return fmt.Errorf("idempotency key contains unsupported character")
}
return nil
}
func randomIdempotencyKey() (string, error) {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "", fmt.Errorf("generate idempotency key: %w", err)
}
return hex.EncodeToString(data[:]), nil
}
func shouldValidateBundle(validate, disable bool) bool {
return validate || !disable
}
func retryDelay(opts RetryOptions, attempt int) time.Duration {
delay := opts.BaseDelay
for index := 1; index < attempt; index++ {
delay *= 2
if delay >= opts.MaxDelay {
return opts.MaxDelay
}
}
return delay
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func isRetryableNetworkError(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout() || netErr.Temporary()
}
return true
}
func joinEndpointPath(endpoint string, elements ...string) string {
parsed, err := url.Parse(endpoint)
if err != nil {
return endpoint
}
parts := []string{}
if parsed.Path != "" && parsed.Path != "/" {
parts = append(parts, strings.Trim(parsed.Path, "/"))
}
parts = append(parts, elements...)
parsed.Path = "/" + path.Join(parts...)
return parsed.String()
}

641
pkg/upload/client_test.go Normal file
View File

@@ -0,0 +1,641 @@
package upload
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
"time"
sourcebundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
func TestNewClientValidatesOptions(t *testing.T) {
tests := []struct {
name string
opts ClientOptions
}{
{name: "missing endpoint", opts: ClientOptions{Token: "secret"}},
{name: "missing token", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080"}},
{name: "bad scheme", opts: ClientOptions{Endpoint: "ftp://127.0.0.1:8080", Token: "secret"}},
{name: "missing host", opts: ClientOptions{Endpoint: "http:///upload", Token: "secret"}},
{name: "query", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080?x=1", Token: "secret"}},
{name: "userinfo", opts: ClientOptions{Endpoint: "http://user@127.0.0.1:8080", Token: "secret"}},
{name: "negative attempts", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080", Token: "secret", Retry: RetryOptions{MaxAttempts: -1}}},
{name: "negative delay", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080", Token: "secret", Retry: RetryOptions{BaseDelay: -1}}},
{name: "max below base", opts: ClientOptions{Endpoint: "http://127.0.0.1:8080", Token: "secret", Retry: RetryOptions{BaseDelay: time.Second, MaxDelay: time.Millisecond}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := NewClient(tt.opts); err == nil {
t.Fatal("NewClient() error = nil, want error")
}
})
}
client, err := NewClient(ClientOptions{Endpoint: "http://127.0.0.1:8080/base/", Token: "secret"})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if got, want := client.uploadURL("reports.daily"), "http://127.0.0.1:8080/base/v1/pipelines/reports.daily/upload"; got != want {
t.Fatalf("upload URL = %q, want %q", got, want)
}
if client.httpClient == nil || client.httpClient.Timeout == 0 {
t.Fatalf("default HTTP client = %#v, want timeout", client.httpClient)
}
}
func TestUploadBundleSendsCallerKeyAndManifestArchive(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{
{path: "report.md", data: "# Report\n"},
{path: "nested/summary.txt", data: "Summary\n"},
})
if err := os.WriteFile(filepath.Join(root, "unlisted.txt"), []byte("nope"), 0o600); err != nil {
t.Fatalf("write unlisted file: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/v1/pipelines/reports.daily/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if got, want := r.Header.Get("Authorization"), "Bearer secret-token"; got != want {
t.Fatalf("authorization = %q, want %q", got, want)
}
if got, want := r.Header.Get("Content-Type"), uploadContentTypeGzip; got != want {
t.Fatalf("content type = %q, want %q", got, want)
}
if got, want := r.Header.Get(idempotencyKeyHeader), "producer.retry:one"; got != want {
t.Fatalf("idempotency key = %q, want %q", got, want)
}
entries := readArchiveEntries(t, r.Body)
if got, want := strings.Join(entryNames(entries), ","), "manifest.json,report.md,nested/summary.txt"; got != want {
t.Fatalf("archive entries = %q, want %q", got, want)
}
if _, ok := entries["unlisted.txt"]; ok {
t.Fatal("archive included unlisted file")
}
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret-token", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
result, err := client.UploadBundle(context.Background(), UploadBundleOptions{
PipelineID: "reports.daily",
Root: root,
IdempotencyKey: "producer.retry:one",
})
if err != nil {
t.Fatalf("UploadBundle() error = %v", err)
}
if result.RunID != "reports.20260604T120000Z.abcdef12" || result.Status != "accepted" {
t.Fatalf("result = %#v, want accepted run", result)
}
}
func TestUploadFilesBuildsTemporaryBundleWithoutTouchingSources(t *testing.T) {
sourceRoot := t.TempDir()
sourcePath := filepath.Join(sourceRoot, "producer-output.md")
if err := os.WriteFile(sourcePath, []byte("producer data\n"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
tempDir := t.TempDir()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/v1/pipelines/reports.files/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
entries := readArchiveEntries(t, r.Body)
if got := string(entries["manifest.json"]); !strings.Contains(got, `"id": "reports.from.files"`) {
t.Fatalf("manifest = %s, want uploaded id", got)
}
if got, want := string(entries["reports/report.md"]), "producer data\n"; got != want {
t.Fatalf("uploaded file = %q, want %q", got, want)
}
if _, ok := entries["producer-output.md"]; ok {
t.Fatal("archive used producer source path instead of bundle path")
}
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.UploadFiles(context.Background(), UploadFilesOptions{
PipelineID: "reports.files",
ID: "reports.from.files",
Files: []sourcebundle.BundleFile{{
SourcePath: sourcePath,
Path: "reports/report.md",
}},
TempDir: tempDir,
})
if err != nil {
t.Fatalf("UploadFiles() error = %v", err)
}
if _, err := os.Stat(filepath.Join(sourceRoot, sourcebundle.ManifestName)); !os.IsNotExist(err) {
t.Fatalf("producer source manifest stat = %v, want not exist", err)
}
entries, err := os.ReadDir(tempDir)
if err != nil {
t.Fatalf("read temp dir: %v", err)
}
if len(entries) != 0 {
t.Fatalf("temp dir entries = %d, want cleanup", len(entries))
}
}
func TestUploadMethodsRequirePipelineIDBeforeLocalWork(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
t.Fatal("server should not receive request")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
missingRoot := filepath.Join(t.TempDir(), "missing")
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: missingRoot}); err == nil || !strings.Contains(err.Error(), "pipeline id is required") {
t.Fatalf("UploadBundle() error = %v, want missing pipeline id", err)
}
tempDir := t.TempDir()
sourcePath := filepath.Join(t.TempDir(), "report.md")
if err := os.WriteFile(sourcePath, []byte("data"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
if _, err := client.UploadFiles(context.Background(), UploadFilesOptions{
ID: "reports.from.files",
Files: []sourcebundle.BundleFile{{
SourcePath: sourcePath,
Path: "report.md",
}},
TempDir: tempDir,
}); err == nil || !strings.Contains(err.Error(), "pipeline id is required") {
t.Fatalf("UploadFiles() error = %v, want missing pipeline id", err)
}
entries, err := os.ReadDir(tempDir)
if err != nil {
t.Fatalf("read temp dir: %v", err)
}
if len(entries) != 0 {
t.Fatalf("temp dir entries = %d, want no local bundle work", len(entries))
}
if got := requests.Load(); got != 0 {
t.Fatalf("requests = %d, want 0", got)
}
}
func TestUploadMethodsRejectInvalidPipelineIDBeforeHTTPRequest(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
t.Fatal("server should not receive request")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
for _, pipelineID := range []string{".reports", "reports/daily", "reports daily"} {
t.Run(pipelineID, func(t *testing.T) {
_, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: pipelineID, Root: root})
if err == nil || !strings.Contains(err.Error(), "pipeline id must be a slug-like identifier") {
t.Fatalf("UploadBundle() error = %v, want invalid pipeline id", err)
}
})
}
if got := requests.Load(); got != 0 {
t.Fatalf("requests = %d, want 0", got)
}
}
func TestUploadBundleValidationFailurePreventsHTTPRequest(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "original"}})
if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("changed"), 0o600); err != nil {
t.Fatalf("mutate bundle file: %v", err)
}
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
t.Fatal("server should not receive request")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root}); err == nil {
t.Fatal("UploadBundle() error = nil, want validation error")
}
if got := requests.Load(); got != 0 {
t.Fatalf("requests = %d, want 0", got)
}
}
func TestUploadBundleCanDisableLocalValidation(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "original"}})
if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("changed"), 0o600); err != nil {
t.Fatalf("mutate bundle file: %v", err)
}
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, DisableValidation: true}); err != nil {
t.Fatalf("UploadBundle() error = %v", err)
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestGeneratedIdempotencyKeyIsReusedAcrossRetry(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
var attempts atomic.Int64
var keys []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/v1/pipelines/reports/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
keys = append(keys, r.Header.Get(idempotencyKeyHeader))
if attempts.Add(1) == 1 {
writeJSONError(w, http.StatusServiceUnavailable, "busy", false)
return
}
writeAccepted(t, w, "reports.20260604T120000Z.abcdef12")
}))
defer server.Close()
client, err := NewClient(ClientOptions{
Endpoint: server.URL,
Token: "secret",
HTTPClient: server.Client(),
Retry: RetryOptions{MaxAttempts: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond},
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root}); err != nil {
t.Fatalf("UploadBundle() error = %v", err)
}
if got, want := attempts.Load(), int64(2); got != want {
t.Fatalf("attempts = %d, want %d", got, want)
}
if len(keys) != 2 || keys[0] == "" || keys[0] != keys[1] {
t.Fatalf("idempotency keys = %#v, want same generated key", keys)
}
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(keys[0]) {
t.Fatalf("generated key = %q, want 128-bit lowercase hex", keys[0])
}
}
func TestUploadResponseParsingAndNoRetryStatuses(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
tests := []struct {
name string
status int
body string
wantConflict bool
wantMessage string
wantRetryable bool
}{
{name: "bad request", status: http.StatusBadRequest, body: `{"error":"bad bundle"}`, wantMessage: "bad bundle"},
{name: "unauthorized", status: http.StatusUnauthorized, body: `{"error":"bad token"}`, wantMessage: "bad token"},
{name: "conflict", status: http.StatusConflict, body: `{"error":"different manifest","retryable":true}`, wantConflict: true, wantMessage: "different manifest", wantRetryable: true},
{name: "too large", status: http.StatusRequestEntityTooLarge, body: `{"error":"too large"}`, wantMessage: "too large"},
{name: "unsupported", status: http.StatusUnsupportedMediaType, body: `{"error":"unsupported"}`, wantMessage: "unsupported"},
{name: "service unavailable", status: http.StatusServiceUnavailable, body: `{"error":"busy"}`, wantMessage: "busy"},
{name: "non json", status: http.StatusBadRequest, body: `plain failure`, wantMessage: "plain failure"},
{name: "unexpected", status: http.StatusTeapot, body: ``, wantMessage: "I'm a teapot"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var attempts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
client, err := NewClient(ClientOptions{
Endpoint: server.URL,
Token: "secret",
HTTPClient: server.Client(),
Retry: RetryOptions{MaxAttempts: 1},
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "key"})
if err == nil {
t.Fatal("UploadBundle() error = nil, want error")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("error = %T %v, want HTTPError", err, err)
}
if httpErr.StatusCode != tt.status || !strings.Contains(httpErr.Message, tt.wantMessage) || httpErr.Retryable != tt.wantRetryable {
t.Fatalf("HTTPError = %#v, want status %d message %q retryable %t", httpErr, tt.status, tt.wantMessage, tt.wantRetryable)
}
var conflict *IdempotencyConflictError
if got := errors.As(err, &conflict); got != tt.wantConflict {
t.Fatalf("conflict error = %t, want %t", got, tt.wantConflict)
}
if got := attempts.Load(); got != 1 {
t.Fatalf("attempts = %d, want 1", got)
}
})
}
}
func TestTokenRedactedFromHTTPError(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
token := "super-secret-token"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusBadRequest, "token "+token+" rejected", false)
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: token, HTTPClient: server.Client(), Retry: RetryOptions{MaxAttempts: 1}})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "key"})
if err == nil {
t.Fatal("UploadBundle() error = nil, want error")
}
if strings.Contains(err.Error(), token) {
t.Fatalf("error exposed token: %v", err)
}
if !strings.Contains(err.Error(), redactedSecret) {
t.Fatalf("error = %v, want redaction marker", err)
}
}
func TestNetworkRetryUsesSameIdempotencyKey(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
var attempts atomic.Int64
var keys []string
client, err := NewClient(ClientOptions{
Endpoint: "http://upload.example",
Token: "secret",
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
keys = append(keys, request.Header.Get(idempotencyKeyHeader))
if got, want := request.URL.Path, "/v1/pipelines/reports/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if attempts.Add(1) == 1 {
return nil, temporaryNetworkError{}
}
return &http.Response{
StatusCode: http.StatusAccepted,
Status: "202 Accepted",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"run_id":"reports.20260604T120000Z.abcdef12","status":"accepted"}`)),
Request: request,
}, nil
})},
Retry: RetryOptions{MaxAttempts: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond},
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
result, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "network-retry"})
if err != nil {
t.Fatalf("UploadBundle() error = %v", err)
}
if result.RunID == "" {
t.Fatalf("result = %#v, want run id", result)
}
if got, want := attempts.Load(), int64(2); got != want {
t.Fatalf("attempts = %d, want %d", got, want)
}
if got, want := strings.Join(keys, ","), "network-retry,network-retry"; got != want {
t.Fatalf("keys = %q, want %q", got, want)
}
}
func TestContextCancellationDuringRetryBackoff(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
ctx, cancel := context.WithCancel(context.Background())
var attempts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
cancel()
writeJSONError(w, http.StatusServiceUnavailable, "busy", false)
}))
defer server.Close()
client, err := NewClient(ClientOptions{
Endpoint: server.URL,
Token: "secret",
HTTPClient: server.Client(),
Retry: RetryOptions{MaxAttempts: 2, BaseDelay: time.Hour, MaxDelay: time.Hour},
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.UploadBundle(ctx, UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "cancel"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("UploadBundle() error = %v, want context.Canceled", err)
}
if got := attempts.Load(); got != 1 {
t.Fatalf("attempts = %d, want 1", got)
}
}
func TestStatusParsesRunStatusAndErrors(t *testing.T) {
acceptedAt := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/runs/reports.20260604T120000Z.abcdef12"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if got, want := r.Header.Get("Authorization"), "Bearer secret"; got != want {
t.Fatalf("authorization = %q, want %q", got, want)
}
_ = json.NewEncoder(w).Encode(RunStatus{
RunID: "reports.20260604T120000Z.abcdef12",
PipelineID: "reports",
Status: "succeeded",
AcceptedAt: acceptedAt,
})
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
status, err := client.Status(context.Background(), "reports.20260604T120000Z.abcdef12")
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if status.RunID != "reports.20260604T120000Z.abcdef12" || status.PipelineID != "reports" || status.Status != "succeeded" || !status.AcceptedAt.Equal(acceptedAt) {
t.Fatalf("status = %#v, want succeeded run", status)
}
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusNotFound, "run not found", false)
}))
defer errorServer.Close()
client, err = NewClient(ClientOptions{Endpoint: errorServer.URL, Token: "secret", HTTPClient: errorServer.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.Status(context.Background(), "missing")
var httpErr *HTTPError
if err == nil || !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusNotFound {
t.Fatalf("Status() error = %v, want 404 HTTPError", err)
}
}
func TestInvalidCallerIdempotencyKeyPreventsHTTPRequest(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "bad key"}); err == nil {
t.Fatal("UploadBundle() error = nil, want invalid key error")
}
if got := requests.Load(); got != 0 {
t.Fatalf("requests = %d, want 0", got)
}
}
type testFile struct {
path string
data string
}
func writeTestBundle(t *testing.T, id string, files []testFile) string {
t.Helper()
sourceRoot := t.TempDir()
bundleFiles := make([]sourcebundle.BundleFile, 0, len(files))
for _, file := range files {
sourcePath := filepath.Join(sourceRoot, filepath.FromSlash(file.path))
if err := os.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil {
t.Fatalf("mkdir source parent: %v", err)
}
if err := os.WriteFile(sourcePath, []byte(file.data), 0o600); err != nil {
t.Fatalf("write source file: %v", err)
}
bundleFiles = append(bundleFiles, sourcebundle.BundleFile{
SourcePath: sourcePath,
Path: file.path,
})
}
root := filepath.Join(t.TempDir(), "bundle")
if _, err := sourcebundle.WriteBundle(sourcebundle.WriteBundleOptions{
Root: root,
ID: id,
Created: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC),
Files: bundleFiles,
}); err != nil {
t.Fatalf("WriteBundle() error = %v", err)
}
return root
}
func readArchiveEntries(t *testing.T, body io.Reader) map[string][]byte {
t.Helper()
gzipReader, err := gzip.NewReader(body)
if err != nil {
t.Fatalf("open gzip archive: %v", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
entries := map[string][]byte{}
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
return entries
}
if err != nil {
t.Fatalf("read tar archive: %v", err)
}
data, err := io.ReadAll(tarReader)
if err != nil {
t.Fatalf("read tar entry %q: %v", header.Name, err)
}
entries[header.Name] = data
}
}
func entryNames(entries map[string][]byte) []string {
ordered := []string{}
for _, name := range []string{"manifest.json", "report.md", "nested/summary.txt", "reports/report.md", "unlisted.txt"} {
if _, ok := entries[name]; ok {
ordered = append(ordered, name)
}
}
return ordered
}
func writeAccepted(t *testing.T, w http.ResponseWriter, runID string) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
if err := json.NewEncoder(w).Encode(Result{RunID: runID, Status: "accepted"}); err != nil {
t.Fatalf("write accepted response: %v", err)
}
}
func writeJSONError(w http.ResponseWriter, status int, message string, retryable bool) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": message, "retryable": retryable})
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
type temporaryNetworkError struct{}
func (temporaryNetworkError) Error() string {
return "temporary network failure"
}
func (temporaryNetworkError) Timeout() bool {
return false
}
func (temporaryNetworkError) Temporary() bool {
return true
}

136
pkg/upload/doc.go Normal file
View File

@@ -0,0 +1,136 @@
// Package upload provides producer-facing helpers for submitting distributor
// source bundles to the HTTP upload API.
//
// The package is intended for Go producer applications that already create
// reports or other Markdown bundle contents and want to hand those bundles to a
// running distributor server. It builds on pkg/bundle for manifest generation,
// path validation, digest calculation, local bundle writing, and local bundle
// validation. It does not expose distributor internals, server configuration,
// storage backends, destination state, or publish behavior.
//
// # Client Construction
//
// NewClient creates a Client from ClientOptions. Endpoint is required and must
// be an http or https distributor server base URL without userinfo, query, or
// fragment. The client derives /v1/pipelines/<pipeline-id>/upload for
// submissions and /runs/<run-id> for status checks. Token is required and is
// sent as Authorization: Bearer <token>. Token values are redacted from errors
// produced by the client.
//
// HTTPClient is optional. When omitted, the package uses a client with a
// conservative timeout. Retry is optional; zero values select safe defaults.
// RetryOptions.MaxAttempts, BaseDelay, and MaxDelay must not be negative, and
// MaxDelay must be greater than or equal to BaseDelay.
//
// # Upload Workflows
//
// UploadBundle uploads an existing local source bundle root to the configured
// PipelineID. PipelineID is required and must match the server's slug-like
// pipeline id syntax. The root must contain manifest.json. By default,
// UploadBundle loads the manifest and validates the complete local bundle with
// pkg/bundle before making any HTTP request. The generated gzip-compressed tar
// archive contains manifest.json and exactly the manifest-listed files;
// unlisted files are not uploaded.
//
// UploadFiles is the convenience workflow for producer applications that have
// generated files but have not yet assembled a bundle directory. PipelineID is
// required and selects the configured distributor workflow. UploadFiles uses
// pkg/bundle to create a temporary complete bundle from explicit
// bundle.BundleFile values, validates it by default, archives it, uploads it,
// and removes temporary files when the call returns. UploadFiles does not write
// into producer source directories. A zero Created timestamp follows
// pkg/bundle defaulting behavior.
//
// The producer contract has four separate identifiers: the bearer token
// authenticates the client, PipelineID selects the distributor workflow, the
// source manifest ID identifies the logical artifact within that workflow, and
// IdempotencyKey identifies one producer run and retry group.
//
// Validation is enabled by default. Set DisableValidation when the application
// has already performed equivalent local validation and wants to skip the
// package's validation step. Validate and DisableValidation must not both be
// true.
//
// # Idempotency And Retry
//
// Every upload request includes Idempotency-Key. If UploadBundleOptions or
// UploadFilesOptions provides IdempotencyKey, the client validates and uses
// that value. Otherwise, it generates a random 128-bit lowercase hexadecimal
// key once for that upload operation and reuses it for all retries from that
// call.
//
// Generated idempotency keys are useful for retrying transient failures within
// a single process call. Producers that need cross-process retry safety should
// provide their own stable key, such as a key derived from the producer job id
// or report id. Valid keys are non-empty ASCII strings up to 128 bytes using
// letters, digits, '.', '_', '-', and ':'.
//
// The client retries only safe cases: 503 Service Unavailable, temporary
// network errors, and ambiguous mid-upload failures. Retries use the same
// idempotency key and replayable gzip archive body. The client does not retry
// 400, 401, 409, 413, 415, or any response after 202 Accepted. Context
// cancellation is honored before each attempt and while waiting between
// retries.
//
// # Results And Errors
//
// Result represents upload admission. A successful UploadBundle or UploadFiles
// call means the server accepted the upload and returned a run id; it does not
// mean the asynchronous distribution run has finished successfully.
//
// Status fetches the current server status for a run id and returns RunStatus.
// This is a separate polling helper; upload calls do not wait for publication
// completion.
//
// Non-2xx upload and status responses return *HTTPError when the server status
// can be represented as an HTTP failure. HTTPError includes the numeric status
// code, HTTP status string, response message, and server retryable flag when
// present. A 409 Conflict response is returned as *IdempotencyConflictError,
// which wraps HTTPError and can be detected with errors.As.
//
// Example: upload producer files with a stable idempotency key.
//
// ctx := context.Background()
// client, err := upload.NewClient(upload.ClientOptions{
// Endpoint: "https://distributor.example.com",
// Token: os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN"),
// })
// if err != nil {
// return err
// }
//
// result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
// PipelineID: "reports.daily",
// ID: "reports.daily.2026-06-06",
// IdempotencyKey: "reports.daily.2026-06-06",
// Files: []bundle.BundleFile{
// {SourcePath: "/tmp/report.md", Path: "report.md"},
// {SourcePath: "/tmp/summary.txt", Path: "summary.txt"},
// },
// })
// if err != nil {
// var conflict *upload.IdempotencyConflictError
// if errors.As(err, &conflict) {
// return fmt.Errorf("upload conflicts with an earlier different bundle: %w", err)
// }
// return err
// }
//
// status, err := client.Status(ctx, result.RunID)
// if err != nil {
// return err
// }
// _ = status
//
// Example: upload an existing bundle root.
//
// result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
// PipelineID: "reports.daily",
// Root: "/var/lib/reports/daily-2026-06-06",
// IdempotencyKey: "reports.daily.2026-06-06",
// })
// if err != nil {
// return err
// }
// _ = result
package upload

94
pkg/upload/types.go Normal file
View File

@@ -0,0 +1,94 @@
package upload
import (
"encoding/json"
"fmt"
"net/http"
"time"
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
)
type Client struct {
endpoint string
token string
httpClient *http.Client
retry RetryOptions
}
type ClientOptions struct {
Endpoint string
Token string
HTTPClient *http.Client
Retry RetryOptions
}
type RetryOptions struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
type UploadBundleOptions struct {
PipelineID string
Root string
Validate bool
DisableValidation bool
IdempotencyKey string
}
type UploadFilesOptions struct {
PipelineID string
ID string
Created time.Time
Files []bundle.BundleFile
Validate bool
DisableValidation bool
TempDir string
IdempotencyKey string
}
type Result struct {
RunID string `json:"run_id"`
Status string `json:"status"`
}
type RunStatus struct {
RunID string `json:"run_id"`
PipelineID string `json:"pipeline_id"`
Status string `json:"status"`
AcceptedAt time.Time `json:"accepted_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Report json.RawMessage `json:"report,omitempty"`
Error string `json:"error,omitempty"`
}
type HTTPError struct {
StatusCode int
Status string
Message string
Retryable bool
}
func (err *HTTPError) Error() string {
if err == nil {
return "<nil>"
}
if err.Message == "" {
return fmt.Sprintf("upload request failed: %s", err.Status)
}
return fmt.Sprintf("upload request failed: %s: %s", err.Status, err.Message)
}
type IdempotencyConflictError struct {
HTTPError
}
func (err *IdempotencyConflictError) Error() string {
return (*HTTPError)(&err.HTTPError).Error()
}
func (err *IdempotencyConflictError) Unwrap() error {
return &err.HTTPError
}