Compare commits
71 Commits
1340418a2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8366af6fb6 | |||
| 5e47d89355 | |||
| 2e0d903626 | |||
| 484fda2514 | |||
| 9a2eaf8e5e | |||
| 4e673dda76 | |||
| cf7e62733e | |||
| 91b72478d5 | |||
| 52078e2195 | |||
| 77cde40296 | |||
| 20129bfff5 | |||
| a95662226f | |||
| d23c624179 | |||
| 69043801d0 | |||
| 8b0ce4d134 | |||
| eba65018be | |||
| 11d1eabe2a | |||
| c02106987f | |||
| 598b665307 | |||
| c4e8ebff6f | |||
| 3da7f931b2 | |||
| b10a8bd194 | |||
| c84d8868d1 | |||
| fc33bbca54 | |||
| e3b92a3b5d | |||
| c804fd604a | |||
| ea562c1c3a | |||
| 6daddad543 | |||
| c67ecf86a9 | |||
| 2abd09bde3 | |||
| de6723c5de | |||
| cb9502f790 | |||
| 9afb3550c4 | |||
| 89169f810f | |||
| eb86cf9ab6 | |||
| ef0b6c1056 | |||
| 93821ea6f9 | |||
| b7db3993fb | |||
| c04432e40b | |||
| 8f3ef33f18 | |||
| ee6a351960 | |||
| 29f01da37b | |||
| bd5892d1f2 | |||
| ce43a6044a | |||
| 1c5d7198e3 | |||
| 9d4694c6d8 | |||
| 033b2e5015 | |||
| 4fa7d1ebb5 | |||
| f98e528c90 | |||
| 25fbfc4677 | |||
| c12ec64066 | |||
| f9142fded4 | |||
| d637949db4 | |||
| a15722571f | |||
| 1a402e6cfa | |||
| 6085344a0b | |||
| c23e8e66ba | |||
| bed425ab78 | |||
| a81f686fae | |||
| ecc5254e6b | |||
| 18bba116f2 | |||
| b19128b77e | |||
| f3fb51ce7b | |||
| 9000e12d47 | |||
| 982e7e9863 | |||
| 2ac2bbdf79 | |||
| 5a3fd2b8ac | |||
| 7cf8f74c3e | |||
| 9143a00bff | |||
| fc16443370 | |||
| 0d346dcdf5 |
21
README.md
21
README.md
@@ -1,18 +1,21 @@
|
||||
# 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, S3-compatible storage, and HTTP upload
|
||||
support: source bundles can be read from local or remote storage, pushed to the
|
||||
upload API, published to local directories or remote paths, and rendered from
|
||||
Markdown 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 local example pipeline:
|
||||
Run the maintained local example:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
See [docs/cli.md](docs/cli.md), [docs/config.md](docs/config.md), [docs/operations.md](docs/operations.md), and [docs/troubleshooting.md](docs/troubleshooting.md) for the implemented CLI, configuration, operating notes, and common failure modes. 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)
|
||||
|
||||
332
docs/cli.md
332
docs/cli.md
@@ -1,210 +1,282 @@
|
||||
# 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
|
||||
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 version [--format text|json]
|
||||
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
||||
distributor reconcile-state --config <path> --pipeline <id> --destination <id> [--all-owners] [--dry-run] [--format text|json]
|
||||
distributor prune --config <path> --pipeline <id> --destination <id> (--dry-run|--apply) [--format text|json]
|
||||
distributor serve [--config <path>]
|
||||
distributor validate [--format text|json] <path>
|
||||
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
||||
distributor inspect [--format text|json] <path>
|
||||
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 --id <bundle-id> [options] <bundle-path>
|
||||
```
|
||||
|
||||
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
||||
- `run`: loads a YAML config, discovers source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
|
||||
- `serve`: loads a YAML config, resolves HTTP upload bearer tokens, and runs the HTTP upload API.
|
||||
- `validate`: validates a local source bundle directory, a local source bundle tree, or one configured pipeline source.
|
||||
- `inspect`: validates source bundles and prints normalized bundle metadata for a local path or one configured pipeline source.
|
||||
- `manifest create`: creates `manifest.json` for a local source bundle directory.
|
||||
- `version` prints the application name and version.
|
||||
- `run` executes configured pipelines against their destinations.
|
||||
- `reconcile-state` repairs destination state records for missing managed outputs.
|
||||
- `prune` deletes managed outputs selected by the destination retention policy when `--apply` is supplied.
|
||||
- `serve` starts the authenticated HTTP upload API defined by the configuration file.
|
||||
- `validate` checks a local bundle path or a configured source bundle.
|
||||
- `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 `local`, `ssh`, and `s3` sources and destinations. `serve` executes configured `http_upload` sources through the upload API and normal destination fan-out.
|
||||
## 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`, `reconcile-state`, `prune`, `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`.
|
||||
- `--dry-run`: load config, discover bundles, inspect destination state, print planned actions and final status, and do not write output files, destination state, or SSH `known_hosts` entries.
|
||||
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
||||
### `run`
|
||||
|
||||
`serve` flags:
|
||||
```sh
|
||||
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
||||
```
|
||||
|
||||
- `--config <path>`: config file to load. If omitted, `serve` uses `/usr/local/etc/distributor/config.yml`.
|
||||
- `--config <path>` loads the pipeline configuration. If omitted, the application uses `/usr/local/etc/distributor/config.yml`.
|
||||
- `--dry-run` validates inputs and reports destination actions without applying changes.
|
||||
- `--force` permits exceptional catalog replacement when a dry run reports `force_replace` for unmanaged content, a planned unmanaged path collision, invalid state, or unsupported future state.
|
||||
- `--format text|json` selects human-readable or machine-readable output.
|
||||
|
||||
`validate` and `inspect` configured source flags:
|
||||
`run` accepts no positional arguments.
|
||||
|
||||
- `--config <path>`: config file to load for source validation or inspection. Required in configured source mode.
|
||||
- `--pipeline <id>`: pipeline source to validate or inspect. Required in configured source mode.
|
||||
- `--bundle <path>`: source-root-relative bundle directory to validate or inspect instead of discovering every bundle under the source root.
|
||||
### `reconcile-state`
|
||||
|
||||
`manifest create` flags:
|
||||
```sh
|
||||
distributor reconcile-state --config <path> --pipeline <id> --destination <id> [--all-owners] [--dry-run] [--format text|json]
|
||||
```
|
||||
|
||||
- `--id <bundle-id>`: source bundle id. Required.
|
||||
- `--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`.
|
||||
- `--config <path>` loads the pipeline configuration and is required.
|
||||
- `--pipeline <id>` selects the pipeline used to identify the destination root and is required.
|
||||
- `--destination <id>` selects the destination root and is required.
|
||||
- `--all-owners` repairs missing managed output records for every owner in the selected catalog state file. Without it, repair is scoped to the selected pipeline and destination owner.
|
||||
- `--dry-run` reports repairs without rewriting `.distributor.json`.
|
||||
- `--format text|json` selects human-readable or machine-readable output.
|
||||
|
||||
`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`.
|
||||
Without `--dry-run`, `reconcile-state` applies state repair by removing records for managed outputs that no longer exist in storage. It reports unmanaged entries but does not delete destination files, adopt unmanaged files, or validate output digests. The command accepts no positional arguments.
|
||||
|
||||
## Common workflows
|
||||
### `prune`
|
||||
|
||||
Validate a source bundle:
|
||||
```sh
|
||||
distributor prune --config <path> --pipeline <id> --destination <id> (--dry-run|--apply) [--format text|json]
|
||||
```
|
||||
|
||||
- `--config <path>` loads the pipeline configuration and is required.
|
||||
- `--pipeline <id>` selects the pipeline used to identify the destination root and owner scope.
|
||||
- `--destination <id>` selects the destination root and owner scope.
|
||||
- `--dry-run` reports planned managed-output deletes without deleting outputs or rewriting `.distributor.json`.
|
||||
- `--apply` deletes planned managed outputs and rewrites `.distributor.json` after confirmed deletes.
|
||||
- `--format text|json` selects human-readable or machine-readable output.
|
||||
|
||||
Exactly one of `--dry-run` or `--apply` is required. The command uses only the selected destination's configured `retention.prune` policy; it does not accept one-off retention overrides. Apply mode deletes only planned managed output paths, preserves unmanaged files, and preserves `.distributor.json` even when no managed outputs remain.
|
||||
|
||||
### `serve`
|
||||
|
||||
```sh
|
||||
distributor serve [--config <path>]
|
||||
```
|
||||
|
||||
- `--config <path>` loads HTTP, source, destination, and pipeline configuration. If omitted, the application uses `/usr/local/etc/distributor/config.yml`.
|
||||
|
||||
`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
|
||||
go run ./cmd/distributor validate examples/source-bundle
|
||||
go run ./cmd/distributor inspect --format json examples/source-bundle
|
||||
```
|
||||
|
||||
Inspect a source bundle:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor inspect examples/source-bundle
|
||||
```
|
||||
|
||||
Validate a configured source without opening destinations:
|
||||
### Validate Or Inspect A Configured Source
|
||||
|
||||
```sh
|
||||
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
|
||||
go run ./cmd/distributor inspect \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--bundle daily/2026-06-01
|
||||
go run ./cmd/distributor manifest create examples/source-bundle --id example-source-bundle --overwrite
|
||||
go run ./cmd/distributor manifest create --id example-source-bundle --overwrite examples/source-bundle
|
||||
```
|
||||
|
||||
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
|
||||
go run ./cmd/distributor manifest create <bundle-path> --id <bundle-id>
|
||||
```
|
||||
|
||||
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 \
|
||||
go run ./cmd/distributor manifest create examples/source-bundle \
|
||||
--id example-source-bundle \
|
||||
--file report.md \
|
||||
--file summary.txt
|
||||
--file summary.txt \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
Preview local publication without writing:
|
||||
### Preview Or Publish A Pipeline
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
|
||||
```
|
||||
|
||||
Publish the local source example:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
Publish the local HTML example:
|
||||
Use `--format json` when automation needs structured run results. Use `--force` only after `--dry-run --force` reports the intended bounded `force_replace` action.
|
||||
|
||||
### Repair Destination State Records
|
||||
|
||||
Preview missing managed output records for one configured destination:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-html.yml
|
||||
go run ./cmd/distributor reconcile-state \
|
||||
--config examples/local-publish.yml \
|
||||
--pipeline example-source-bundle \
|
||||
--destination local-archive \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Start the HTTP upload API:
|
||||
Apply the repair after reviewing the report:
|
||||
|
||||
```sh
|
||||
DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN=<token> \
|
||||
go run ./cmd/distributor serve --config examples/http-upload-local.yml
|
||||
go run ./cmd/distributor reconcile-state \
|
||||
--config examples/local-publish.yml \
|
||||
--pipeline example-source-bundle \
|
||||
--destination local-archive
|
||||
```
|
||||
|
||||
Upload an archive to the configured `http_upload` pipeline associated with a bearer token:
|
||||
Use `--all-owners` only when every owner inside the selected catalog root should be repaired.
|
||||
|
||||
### Prune Managed Outputs
|
||||
|
||||
Preview managed outputs selected by the configured retention policy:
|
||||
|
||||
```sh
|
||||
curl -X POST http://127.0.0.1:8080/upload \
|
||||
-H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
|
||||
-H "Content-Type: application/gzip" \
|
||||
--data-binary @bundle.tar.gz
|
||||
go run ./cmd/distributor prune \
|
||||
--config examples/local-publish.yml \
|
||||
--pipeline example-source-bundle \
|
||||
--destination local-archive \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
The upload response is accepted asynchronously:
|
||||
|
||||
```json
|
||||
{"run_id":"reports.20260603T120000Z.abcdef12","status":"accepted"}
|
||||
```
|
||||
|
||||
Check upload status:
|
||||
Apply after reviewing the report:
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:8080/runs/<run-id>
|
||||
go run ./cmd/distributor prune \
|
||||
--config examples/local-publish.yml \
|
||||
--pipeline example-source-bundle \
|
||||
--destination local-archive \
|
||||
--apply
|
||||
```
|
||||
|
||||
Check server readiness:
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
Preview local fan-out publication:
|
||||
Use `--format json` when automation needs structured prune results.
|
||||
|
||||
### Run HTML And Fan-Out Examples
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Preview local archive-plus-latest publication:
|
||||
|
||||
```sh
|
||||
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
|
||||
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:
|
||||
|
||||
- `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:
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -216,20 +288,20 @@ 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` summaries include `publish_new`, `upsert_additive`, `replace_catalog`, `skip_same`, `force_replace`, `fail_unmanaged`, and `fail_conflict` counters. Destination action records use the same stable action values and include the resolved `destination_path`.
|
||||
- `skip_same` means the planned outputs already match valid catalog metadata, so `run` does not write outputs, rewrite `.distributor.json`, delete files, or notify. The decision is based on catalog metadata and does not read destination file bytes.
|
||||
|
||||
`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:
|
||||
|
||||
- `version`: application name and version.
|
||||
- `validate`: bundle count and discovered bundle identifiers. Configured source results also include pipeline id and source backend.
|
||||
- `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.
|
||||
- `manifest create`: manifest path, bundle root, id, created timestamp, digest, file count, and file records.
|
||||
- `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).
|
||||
- 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.
|
||||
- Use `run --dry-run` before publishing to review destination actions.
|
||||
- Use `reconcile-state --dry-run` to inspect missing managed output records before repairing destination state.
|
||||
- Use `prune --dry-run` before `prune --apply` to review configured retention deletes.
|
||||
- Use [Configuration](config.md) for schema and default details.
|
||||
- Use [Troubleshooting](troubleshooting.md) for common errors and corrective action.
|
||||
- Use [Operations](operations.md) for HTTP upload operation, state files, and recovery workflows.
|
||||
|
||||
540
docs/config.md
540
docs/config.md
@@ -1,21 +1,27 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Config File Location
|
||||
Audience: administrators, operators, and advanced users who write YAML configuration for `distributor`.
|
||||
|
||||
`distributor run --config <path>` and `distributor serve --config <path>` load
|
||||
the YAML config at the provided path.
|
||||
This is the canonical user-facing configuration reference. CLI syntax lives in [CLI](cli.md), operations guidance lives in [Operations](operations.md), recovery guidance lives in [Troubleshooting](troubleshooting.md), and file-format contracts live under [Integrations](integrations/source-bundle.md).
|
||||
|
||||
If `--config` is omitted, both commands use:
|
||||
## Config File Loading
|
||||
|
||||
`distributor run --config <path>` and `distributor serve --config <path>` load the YAML file at `<path>`. If `--config` is omitted, commands use:
|
||||
|
||||
```text
|
||||
/usr/local/etc/distributor/config.yml
|
||||
```
|
||||
|
||||
Config parsing rejects unknown YAML fields. The executable `run` backends are
|
||||
`local`, `ssh`, and `s3`. The `serve` command executes `http_upload` sources
|
||||
through the HTTP upload API and normal destination fan-out.
|
||||
YAML decoding rejects unknown fields. Defaults are applied after decoding and before validation.
|
||||
|
||||
## Minimal Local Config
|
||||
Runtime backend support is command-specific:
|
||||
|
||||
- `run`, `validate --config`, and `inspect --config` execute `local`, `ssh`, and `s3` sources.
|
||||
- `run`, `reconcile-state`, and `prune` execute `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 Working Config
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
@@ -29,9 +35,9 @@ pipelines:
|
||||
path: /srv/reports/archive
|
||||
```
|
||||
|
||||
This publishes source files only. It uses the default validation and transfer policies.
|
||||
This publishes source files only. It uses default validation, additive workflow, preserve-relative path mapping, source-only publish policy, disabled pruning, and default HTTP server values.
|
||||
|
||||
## Production-Oriented Local Config
|
||||
## Production-Oriented Config
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -42,6 +48,8 @@ server:
|
||||
queue_size: 16
|
||||
max_concurrency: 1
|
||||
retention: 24h
|
||||
secrets:
|
||||
directory: /run/secrets/distributor
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
@@ -53,21 +61,20 @@ pipelines:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
workflow: additive
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
transfer:
|
||||
on_destination_same: skip
|
||||
on_destination_older: replace
|
||||
on_destination_newer: skip
|
||||
on_conflict: fail
|
||||
path_mapping:
|
||||
mode: preserve_relative
|
||||
retention:
|
||||
prune:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
## HTTP Upload Source Configuration
|
||||
## HTTP Upload Source Config
|
||||
|
||||
HTTP upload sources are configured as pipeline sources only. They are not valid
|
||||
destination backends. `distributor serve` maps each configured upload token to
|
||||
exactly one `http_upload` pipeline.
|
||||
HTTP upload sources are configured on pipelines and 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:
|
||||
@@ -78,72 +85,222 @@ server:
|
||||
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
|
||||
token_env: WEATHER_DAILY_UPLOAD_TOKEN
|
||||
staging_path: /var/spool/distributor/weather-daily
|
||||
max_upload_size: 20MB
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
```
|
||||
|
||||
`source.token_env` is required and names the environment variable or `secrets.directory` file that provides the bearer token. Literal upload tokens are not supported in YAML.
|
||||
`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.
|
||||
|
||||
`source.staging_path` is optional. When omitted, it defaults to `<server.http.staging_root>/<pipeline id>`.
|
||||
For `http_upload` sources, `staging_path` defaults to `<server.http.staging_root>/<pipeline id>`. `max_upload_size` defaults to `server.http.max_upload_size`.
|
||||
|
||||
`source.max_upload_size` is optional. When omitted, it defaults to `server.http.max_upload_size`.
|
||||
## Top-Level Fields
|
||||
|
||||
The server resolves each `token_env` through the real process environment and
|
||||
the configured `secrets.directory` resolver. Startup fails if any configured
|
||||
upload token is missing, empty, or resolves to the same value as another upload
|
||||
pipeline. Token values are not read from YAML and are not printed in API
|
||||
responses.
|
||||
### `server.http`
|
||||
|
||||
## HTTP Upload API
|
||||
`server.http` controls the HTTP upload server used by `serve`.
|
||||
|
||||
`distributor serve` binds to `server.http.bind`, which defaults to
|
||||
`127.0.0.1:8080`.
|
||||
- `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`.
|
||||
|
||||
Routes:
|
||||
Numeric server values and durations must be greater than zero after defaults are applied.
|
||||
|
||||
- `GET /healthz`: returns readiness status after config and upload tokens load.
|
||||
- `POST /upload`: accepts one tar or tar.gz source bundle archive.
|
||||
- `GET /runs/<run_id>`: returns an in-memory upload status record, or `404` if the run id is unknown or expired.
|
||||
### `secrets`
|
||||
|
||||
`POST /upload` authenticates with:
|
||||
- `directory`: optional directory of secret files used by the config-owned credential resolver.
|
||||
|
||||
```text
|
||||
Authorization: Bearer <token>
|
||||
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.
|
||||
- `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.
|
||||
- `source`: required source backend config.
|
||||
- `validation`: optional validation policy.
|
||||
- `destinations`: required non-empty destination list.
|
||||
|
||||
Slug-like identifiers must start with a letter or number and may contain letters, numbers, `.`, `_`, and `-`.
|
||||
|
||||
## Backend Reference
|
||||
|
||||
### Local Backend
|
||||
|
||||
Local backends can be used as sources and destinations.
|
||||
|
||||
```yaml
|
||||
backend: local
|
||||
path: /srv/distributor/archive
|
||||
```
|
||||
|
||||
The token selects the configured `http_upload` pipeline. Producers do not send a
|
||||
pipeline id. Requests with a submitted `pipeline` or `pipeline_id` query value
|
||||
are rejected.
|
||||
- `backend`: required value `local`.
|
||||
- `path`: required local filesystem root for this backend.
|
||||
|
||||
Accepted upload content types:
|
||||
### SSH/SFTP Backend
|
||||
|
||||
- `application/x-tar`
|
||||
- `application/gzip`
|
||||
- `application/x-gzip`
|
||||
SSH backends use native SFTP and can be used as sources and destinations. Adapter behavior is documented in [SSH/SFTP Integration](integrations/ssh-sftp.md).
|
||||
|
||||
Accepted uploads return:
|
||||
|
||||
```json
|
||||
{"run_id":"<id>","status":"accepted"}
|
||||
```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
|
||||
```
|
||||
|
||||
The run id can be queried through `GET /runs/<run_id>` while the status record
|
||||
is retained in memory. Completed records expire after `server.http.retention`;
|
||||
expiration also removes committed staged bundle directories for completed
|
||||
uploads.
|
||||
- `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`.
|
||||
|
||||
## HTML Publication
|
||||
Accepted host key policy values are `strict` or boolean `true`, `accept-new`, and `off` or boolean `false`. Authentication uses SSH agent identities when `SSH_AUTH_SOCK` is available, then `ssh_key_file` when configured. Password authentication is not configured in YAML.
|
||||
|
||||
To publish generated sidecar HTML from Markdown files:
|
||||
### S3-Compatible Backend
|
||||
|
||||
S3 backends can be used as sources and destinations. Adapter 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 workflow, publishing, transforms, path mapping, links, and retention.
|
||||
|
||||
```yaml
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
workflow: additive
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
path_mapping:
|
||||
mode: preserve_relative
|
||||
```
|
||||
|
||||
- `id`: required unique slug-like identifier within the pipeline.
|
||||
- Backend fields: required according to the selected destination backend.
|
||||
- `workflow`: optional catalog update workflow. Default: `additive`.
|
||||
- `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.
|
||||
- `retention`: optional managed-output retention policy.
|
||||
|
||||
Destination ids must be unique within a pipeline.
|
||||
|
||||
Pre-workflow destination policy keys for state mode, conflict handling,
|
||||
ownership adoption, and per-comparison copy decisions are not accepted config
|
||||
fields. YAML files containing those keys fail during config loading.
|
||||
|
||||
## Destination Workflow
|
||||
|
||||
```yaml
|
||||
workflow: additive
|
||||
```
|
||||
|
||||
- `workflow`: optional. Accepted values are `additive` and `replacement`; default is `additive`.
|
||||
|
||||
`additive` writes planned outputs into the catalog and retains unrelated catalog-managed outputs in the same destination bundle path. Existing catalog records for planned paths are replaced by the current publication. A planned path that exists in storage but is not recorded in valid catalog state fails as unmanaged content unless `run --force` selects `force_replace`.
|
||||
|
||||
`replacement` writes planned outputs for the current pipeline and destination, and removes catalog outputs owned by the same pipeline and destination when those outputs are omitted from the new plan. Outputs owned by other pipeline/destination pairs remain catalog-managed. Replacement workflow is normal managed behavior and does not require `--force`.
|
||||
|
||||
Use `replacement` for stable latest-style destinations where the current owner should publish exactly the currently planned output set. Use `additive` when a destination root intentionally accumulates outputs over time or receives disjoint outputs from multiple configured destinations.
|
||||
|
||||
## 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
|
||||
publish:
|
||||
@@ -153,248 +310,117 @@ transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
css_href: /assets/report.css
|
||||
```
|
||||
|
||||
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
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: index
|
||||
input: report.md
|
||||
```
|
||||
- `transform.markdown_to_html.enabled`: enables Markdown-to-HTML generation for this destination.
|
||||
- `transform.markdown_to_html.mode`: optional. Accepted values are `sidecar` and `index`; default is `sidecar` when a Markdown transform block is present.
|
||||
- `transform.markdown_to_html.input`: optional source manifest path for `index` mode only.
|
||||
- `transform.markdown_to_html.css_href`: optional stylesheet href to link from generated HTML.
|
||||
|
||||
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.
|
||||
|
||||
`css_href` may be an absolute `http` or `https` URL, a root-relative path such as `/assets/report.css`, or a relative URL path such as `assets/report.css`. Query strings are allowed. `distributor` injects the href as a `<link rel="stylesheet">` element but does not copy, publish, verify, or manage the CSS file solely because `css_href` is set.
|
||||
|
||||
At least one output type must be enabled. Enabled Markdown transforms are rejected when `publish.html` is `false`, `input` is rejected unless `mode` is `index`, and `css_href` is rejected when the Markdown transform is disabled.
|
||||
|
||||
## Destination Path Mapping
|
||||
|
||||
Each destination chooses how source bundle paths map into that destination:
|
||||
|
||||
```yaml
|
||||
path_mapping:
|
||||
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
|
||||
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` 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.
|
||||
|
||||
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. 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`.
|
||||
Fixed mapping is useful for stable latest-style paths. Preview fixed destinations with `run --dry-run`, especially before using `--force`.
|
||||
|
||||
## Destination Links
|
||||
|
||||
Destinations can record public URLs for published outputs:
|
||||
|
||||
```yaml
|
||||
links:
|
||||
base_url: https://reports.example.com/archive
|
||||
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.
|
||||
- `html`: use the first generated HTML 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 no primary URL is reported for the run.
|
||||
|
||||
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.
|
||||
## Retention Policy
|
||||
|
||||
## Reference
|
||||
```yaml
|
||||
retention:
|
||||
prune:
|
||||
enabled: false
|
||||
older_than: 168h
|
||||
keep_latest: 3
|
||||
```
|
||||
|
||||
Top level:
|
||||
- `retention.prune.enabled`: optional boolean. Default is `false`.
|
||||
- `retention.prune.older_than`: optional duration. When pruning is enabled, outputs older than this duration are eligible for pruning.
|
||||
- `retention.prune.keep_latest`: optional non-negative integer. When pruning is enabled, this many newest managed outputs are preserved before age-based pruning is considered.
|
||||
|
||||
- `server.http.bind`: optional HTTP bind address; defaults to `127.0.0.1:8080`.
|
||||
- `server.http.staging_root`: optional root for default HTTP upload staging paths; defaults to `/var/spool/distributor`.
|
||||
- `server.http.max_upload_size`: optional default upload size limit; defaults to `20MB`.
|
||||
- `server.http.queue_size`: optional HTTP upload admission queue size; defaults to `16`.
|
||||
- `server.http.max_concurrency`: optional HTTP upload worker concurrency; defaults to `1`.
|
||||
- `server.http.retention`: optional completed upload retention duration; defaults to `24h`.
|
||||
- `secrets.directory`: optional credential secrets directory.
|
||||
- `pipelines`: required non-empty list.
|
||||
When `retention.prune.enabled` is `true`, at least one of `older_than` or `keep_latest` is required. `older_than` must be greater than zero, and `keep_latest` must be zero or greater.
|
||||
|
||||
Pipeline:
|
||||
Pruning uses catalog output `updated_at` timestamps from destination state. If both `keep_latest` and `older_than` are set, the newest `keep_latest` outputs are preserved first, then age-based pruning is applied to the remaining managed outputs.
|
||||
|
||||
- `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.
|
||||
- `token_env`: required for `http_upload`; names the token environment variable or secret-file name.
|
||||
- `staging_path`: optional for `http_upload`; defaults below `server.http.staging_root` using the pipeline id.
|
||||
- `max_upload_size`: optional for `http_upload`; defaults to `server.http.max_upload_size`.
|
||||
|
||||
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`.
|
||||
- `http_upload`: source-only configuration; requires `token_env`.
|
||||
The `prune` command is scoped to the selected pipeline and destination owner. `prune --dry-run` reports selected managed outputs without writing. `prune --apply` deletes only selected managed output paths and rewrites destination state after confirmed deletes. It does not delete unmanaged files or `.distributor.json`, and it does not run automatically after `run`.
|
||||
|
||||
## Size And Duration Values
|
||||
|
||||
Upload size fields use an integer plus one of the supported binary-size suffixes:
|
||||
Upload size fields must be YAML strings with an integer and one of these suffixes:
|
||||
|
||||
- `B`
|
||||
- `KB`
|
||||
- `MB`
|
||||
- `GB`
|
||||
|
||||
Suffix multipliers use powers of 1024. Size values must be greater than zero after defaults are applied.
|
||||
Suffix multipliers use powers of 1024. Values must be greater than zero after defaults are applied.
|
||||
|
||||
HTTP retention uses Go-style duration strings such as `24h`, `90m`, or `168h`. Retention must be greater than zero after defaults are applied.
|
||||
|
||||
## SSH Backend
|
||||
|
||||
SSH uses native SFTP. It can be used for sources, destinations, or both:
|
||||
|
||||
```yaml
|
||||
backend: ssh
|
||||
host: example.com
|
||||
user: distributor
|
||||
port: 2222
|
||||
path: /remote/root
|
||||
ssh_key_file: /home/distributor/.ssh/id_ed25519
|
||||
known_hosts: /home/distributor/.ssh/known_hosts
|
||||
host_key_policy: accept-new
|
||||
```
|
||||
|
||||
Authentication uses SSH agent identities first when `SSH_AUTH_SOCK` is set, then `ssh_key_file` if configured. Password authentication in YAML is not supported.
|
||||
|
||||
Host key policies:
|
||||
|
||||
- `strict`, `true`, and `"true"` require a matching known host key.
|
||||
- `accept-new` accepts and persists a new host key, but fails if an existing key changed. During `run --dry-run`, new host keys are accepted only for the current connection and are not persisted.
|
||||
- `off`, `false`, and `"false"` disable host key checking and are insecure.
|
||||
|
||||
`accept-new` and `strict` use `known_hosts` when configured. If omitted, distributor uses the current service user's default OpenSSH `known_hosts` path where practical. `accept-new` fails when it needs to persist a new host key and no writable `known_hosts` path is available. It does not create a missing parent `.ssh` directory.
|
||||
|
||||
## S3 Backend
|
||||
|
||||
S3 uses the AWS SDK for Go v2 and supports S3-compatible endpoints:
|
||||
|
||||
```yaml
|
||||
backend: s3
|
||||
endpoint: https://s3.example.com
|
||||
bucket: reports
|
||||
prefix: archive
|
||||
region: us-east-1
|
||||
force_path_style: true
|
||||
credentials:
|
||||
access_key_id_env: DISTRIBUTOR_S3_ACCESS_KEY_ID
|
||||
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
`endpoint` and `bucket` are required. `prefix` is an optional backend root; it is treated as an object-key prefix, not a real directory. Prefixes must be clean slash-separated paths after trimming leading and trailing slashes. `http://` endpoints are allowed for explicitly configured local development or local S3-compatible test services.
|
||||
|
||||
If either credential environment variable name is configured, both must be configured and both referenced variables must resolve to non-empty values through the real process environment or `secrets.directory`. Explicit credentials take precedence over the AWS SDK default credential chain. If credential environment variable names are omitted, the SDK default credential chain is used and `secrets.directory` values are not injected into the process environment.
|
||||
|
||||
Publish policy:
|
||||
|
||||
- `publish.source`: publish source artifacts.
|
||||
- `publish.html`: publish generated HTML artifacts from Markdown source files.
|
||||
|
||||
At least one output type must be enabled. When `publish.html` is true, `transform.markdown_to_html.enabled` must be `true`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Defaults
|
||||
|
||||
Defaults are applied after YAML decoding and before validation:
|
||||
|
||||
- `validation.on_digest_mismatch: fail`
|
||||
- SSH `port: 22`
|
||||
- SSH `host_key_policy: accept-new`
|
||||
- S3 `region: us-east-1`
|
||||
- S3 `force_path_style: true`
|
||||
- `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`
|
||||
- `source.staging_path: /var/spool/distributor/<pipeline id>` for `http_upload`
|
||||
- `source.max_upload_size: server.http.max_upload_size` for `http_upload`
|
||||
- `transform.markdown_to_html.mode: sidecar` when a Markdown-to-HTML transform block is present and mode is omitted
|
||||
- `publish.source: true`
|
||||
- `publish.html: false`
|
||||
- `validation.on_digest_mismatch: fail`
|
||||
- SSH `port: 22`
|
||||
- SSH `host_key_policy: accept-new`
|
||||
- S3 `region: us-east-1`
|
||||
- S3 `prefix`: leading and trailing slashes trimmed
|
||||
- S3 `force_path_style: true`
|
||||
- `http_upload` source `staging_path: <server.http.staging_root>/<pipeline id>`
|
||||
- `http_upload` source `max_upload_size: server.http.max_upload_size`
|
||||
- `workflow: additive`
|
||||
- `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`
|
||||
- `links.primary: auto` when a `links` block is present and `primary` is omitted
|
||||
- `transfer.on_destination_same: skip`
|
||||
- `transfer.on_destination_older: replace`
|
||||
- `transfer.on_destination_newer: skip`
|
||||
- `transfer.on_conflict: fail`
|
||||
- `retention.prune.enabled: false`
|
||||
|
||||
## Secrets
|
||||
|
||||
@@ -405,29 +431,33 @@ secrets:
|
||||
directory: /run/secrets/distributor
|
||||
```
|
||||
|
||||
Each regular file in the directory becomes an internal credential environment value named by the filename. Valid filenames must match `[A-Za-z_][A-Za-z0-9_]*`. Directories are ignored, and symlinks to regular files are followed. Exactly one trailing LF or CRLF is trimmed from each file; other whitespace is preserved.
|
||||
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.secret_access_key_env`
|
||||
- `upload_tokens[].token_env`
|
||||
|
||||
HTTP upload tokens name one environment variable or secret-file name:
|
||||
## Maintained Examples
|
||||
|
||||
- `source.token_env`
|
||||
Maintained examples live under [examples](../examples/). Config tests load these YAML files.
|
||||
|
||||
## Examples
|
||||
Local examples:
|
||||
|
||||
Maintained examples live under [examples](../examples/):
|
||||
- `local-to-local.yml`: minimal local-to-local config using absolute sample paths; load-tested, but paths should be adapted before running.
|
||||
- `local-publish.yml`: runnable local source publication used by the README quickstart.
|
||||
- `local-html.yml`: local sidecar HTML publication.
|
||||
- `local-index.yml`: local `index.html` publication.
|
||||
- `fan-out.yml`: local fan-out publication to source and HTML destinations.
|
||||
- `archive-and-latest.yml`: local archive plus fixed latest publication.
|
||||
- `additive-workflow.yml`: two destinations publishing disjoint outputs into one catalog-managed root.
|
||||
- `replacement-workflow.yml`: fixed-path replacement workflow for a stable latest-style output set.
|
||||
- `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`.
|
||||
|
||||
- `local-to-local.yml`: minimal local config.
|
||||
- `local-publish.yml`: runnable local source publication.
|
||||
- `local-html.yml`: runnable local HTML publication.
|
||||
- `local-index.yml`: runnable local `index.html` publication.
|
||||
- `fan-out.yml`: runnable local fan-out publication to source and HTML destinations.
|
||||
- `archive-and-latest.yml`: runnable local fan-out publication to an archive destination and a fixed latest destination.
|
||||
- `http-upload-local.yml`: local HTTP upload server example with a token environment variable reference.
|
||||
- `ssh-destination.yml`: environment-gated local-to-SSH publication example.
|
||||
- `s3-destination.yml`: environment-gated local-to-S3 publication example.
|
||||
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
136
docs/consumers/api.md
Normal 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.
|
||||
90
docs/consumers/pkg-bundle.md
Normal file
90
docs/consumers/pkg-bundle.md
Normal 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.
|
||||
122
docs/consumers/pkg-upload.md
Normal file
122
docs/consumers/pkg-upload.md
Normal 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.
|
||||
129
docs/integrations/destination-state.md
Normal file
129
docs/integrations/destination-state.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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 catalog planning, managed replacement, retention pruning, repair, and recovery.
|
||||
|
||||
## Catalog State Schema
|
||||
|
||||
Publish execution writes catalog state with `schema_version` `4`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 4,
|
||||
"distributor_version": "dev",
|
||||
"created_at": "2026-06-04T12:00:00Z",
|
||||
"updated_at": "2026-06-04T12:10:00Z",
|
||||
"state": {
|
||||
"mode": "catalog"
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"path": "report.html",
|
||||
"pipeline_id": "reports",
|
||||
"destination_id": "static-site",
|
||||
"source": {
|
||||
"id": "reports.example.2026-06-04",
|
||||
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"created": "2026-06-04T11:55:00Z"
|
||||
},
|
||||
"kind": "generated",
|
||||
"source_path": "report.md",
|
||||
"transform": "markdown_to_html",
|
||||
"url": "https://reports.example.com/archive/report.html",
|
||||
"sha256": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"size": 2345,
|
||||
"created_at": "2026-06-04T12:00:00Z",
|
||||
"updated_at": "2026-06-04T12:10:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Top-level fields:
|
||||
|
||||
- `schema_version`: required value `4`.
|
||||
- `distributor_version`: optional application version string.
|
||||
- `created_at`: RFC3339 timestamp for when this catalog record was first created.
|
||||
- `updated_at`: RFC3339 timestamp for the latest catalog update.
|
||||
- `state.mode`: required value `catalog`.
|
||||
- `outputs`: required array of catalog output records. Empty is valid.
|
||||
|
||||
## Output Records
|
||||
|
||||
Each output record has:
|
||||
|
||||
- `path`: destination-bundle-relative output path.
|
||||
- `pipeline_id`: configured pipeline id that manages the output path.
|
||||
- `destination_id`: configured destination id that manages the output path.
|
||||
- `source`: compact source identity for the output.
|
||||
- `kind`: `source` or `generated`.
|
||||
- `source_path`: generated outputs only; source manifest path used to derive the output.
|
||||
- `transform`: generated outputs only; transform id, currently `markdown_to_html`.
|
||||
- `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.
|
||||
- `created_at`: RFC3339 timestamp for when this output path was first recorded as managed.
|
||||
- `updated_at`: RFC3339 timestamp for when this output path was last written or updated in state.
|
||||
|
||||
Output paths must be unique and use clean relative slash-separated path rules. `pipeline_id` and `destination_id` must be slug-like identifiers.
|
||||
|
||||
For copied source outputs, `source_path` and `transform` are omitted. For generated outputs, both fields are required.
|
||||
|
||||
## Source Identity
|
||||
|
||||
Each output records the source identity that produced it:
|
||||
|
||||
- `source.id`: source manifest id.
|
||||
- `source.digest`: source manifest digest.
|
||||
- `source.created`: source manifest creation timestamp.
|
||||
|
||||
The full source manifest is not embedded in catalog state. The source bundle's `manifest.json` remains the producer contract, and `.distributor.json` records only the source identity needed for catalog ownership and later maintenance workflows.
|
||||
|
||||
## Workflow Semantics
|
||||
|
||||
Destination `workflow` is runtime configuration and is not persisted in `.distributor.json`.
|
||||
|
||||
`workflow: additive` writes planned outputs and retains unrelated catalog-managed outputs. If a planned path already has a catalog output record, the current publication replaces that record and overwrites the file. If a planned path exists in storage but is not recorded in valid catalog state, planning fails as unmanaged unless `run --force` selects `force_replace`.
|
||||
|
||||
`workflow: replacement` writes planned outputs for the current pipeline and destination and removes omitted outputs owned by that same pipeline and destination. Outputs owned by other pipeline/destination pairs remain catalog-managed. This is normal managed replacement and does not require `--force`.
|
||||
|
||||
`force_replace` is an explicit per-run recovery path. It deletes only the resolved destination bundle path, then writes planned outputs and fresh catalog state. It can replace unmanaged content, planned unmanaged path collisions, invalid destination state, and unsupported future destination state after dry-run review.
|
||||
|
||||
## Publish Planning Outcomes
|
||||
|
||||
Current run reports use these destination action labels:
|
||||
|
||||
- `publish_new`: no valid state exists and the destination bundle path is empty.
|
||||
- `upsert_additive`: valid catalog state exists and additive workflow will write the planned outputs.
|
||||
- `replace_catalog`: valid catalog state exists and replacement workflow will write the planned outputs and remove omitted outputs for the current owner.
|
||||
- `skip_same`: no-op action value in the run output vocabulary.
|
||||
- `force_replace`: explicit bounded destructive replacement selected by `--force`.
|
||||
- `fail_unmanaged`: unmanaged destination content prevents publication.
|
||||
- `fail_conflict`: invalid state or unsupported state prevents publication without explicit force.
|
||||
|
||||
Schema versions older than `4` are superseded legacy state for publish planning. Normal catalog planning may publish over superseded legacy state according to the configured workflow, while invalid state and unsupported future schema versions fail unless `--force` is explicitly selected.
|
||||
|
||||
## Repair Semantics
|
||||
|
||||
`distributor reconcile-state` removes catalog output records for files that no longer exist in destination storage. It uses the configured pipeline and destination selector to open one destination root and reads that root's `.distributor.json`.
|
||||
|
||||
By default, repair is scoped to output records whose `pipeline_id` and `destination_id` match the selected pipeline and destination. With `--all-owners`, it checks every catalog output record in the selected root.
|
||||
|
||||
The command reports missing managed outputs and unmanaged storage entries. Without `--dry-run`, it removes missing managed output records from valid catalog state and rewrites `.distributor.json`. It does not delete destination files, adopt unmanaged entries, validate output digests, or rewrite invalid state.
|
||||
|
||||
## Prune Semantics
|
||||
|
||||
`distributor prune` deletes catalog output paths selected by the configured destination `retention.prune` policy. It uses the configured pipeline and destination selector to open one destination root and reads that root's `.distributor.json`.
|
||||
|
||||
Prune planning is scoped to output records whose `pipeline_id` and `destination_id` match the selected pipeline and destination. It uses output `updated_at` timestamps. `prune --dry-run` reports planned managed-output deletes without deleting files or rewriting state. `prune --apply` deletes only planned managed output paths, removes confirmed deleted records from valid catalog state, and updates the state timestamp. It does not delete unmanaged files or `.distributor.json`.
|
||||
|
||||
## 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.
|
||||
|
||||
Before changing this contract, inspect and run:
|
||||
|
||||
```sh
|
||||
go test ./internal/state ./internal/publish
|
||||
```
|
||||
152
docs/integrations/http-upload.md
Normal file
152
docs/integrations/http-upload.md
Normal 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
|
||||
```
|
||||
@@ -1,39 +1,42 @@
|
||||
# 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
|
||||
|
||||
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, or source manifest 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.
|
||||
- `index`: renders one selected Markdown source to `index.html` at the destination bundle path.
|
||||
- `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 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`.
|
||||
|
||||
When `transform.markdown_to_html.css_href` is set, generated HTML includes a stylesheet link in the document head. The href may be an absolute HTTP(S) URL, a root-relative path, or a relative URL path. Distributor treats this as a link reference only; it does not copy, publish, verify, or manage the CSS file solely because `css_href` is configured.
|
||||
|
||||
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:
|
||||
|
||||
- `<!doctype html>`
|
||||
- `<html lang="en">`
|
||||
- UTF-8 `<meta charset>`
|
||||
- optional `<link rel="stylesheet" href="...">` when `css_href` is configured
|
||||
- empty `<title>`
|
||||
- `<body>` containing the rendered Markdown body
|
||||
|
||||
The wrapper is deterministic and does not read configuration, templates, CSS, or source manifest metadata.
|
||||
The wrapper is deterministic. When `css_href` is omitted, the generated wrapper is unchanged from the unstyled output. When `css_href` is configured, its escaped link element is part of the generated output bytes.
|
||||
|
||||
## Output metadata
|
||||
## Output Metadata
|
||||
|
||||
Generated outputs record:
|
||||
|
||||
@@ -43,18 +46,16 @@ Generated outputs record:
|
||||
- SHA-256 digest 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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Markdown rendering does not mutate source bundles, publish files, write `.distributor.json`, select destination actions, or choose catalog workflow behavior. Publish planning decides whether generated HTML is selected for a destination and destination state records the generated output metadata.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing Markdown renderer behavior, inspect and run:
|
||||
|
||||
```bash
|
||||
```sh
|
||||
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
73
docs/integrations/s3.md
Normal 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 managed replacement and failed-write cleanup delete only managed output objects plus `.distributor.json`. Forced replacement deletes objects under the bounded destination bundle prefix, then writes planned outputs and schema version `4` catalog state. For fixed-path destinations, that bounded prefix is the configured backend root. 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.
|
||||
94
docs/integrations/source-bundle.md
Normal file
94
docs/integrations/source-bundle.md
Normal 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
|
||||
```
|
||||
71
docs/integrations/ssh-sftp.md
Normal file
71
docs/integrations/ssh-sftp.md
Normal 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, then writes planned outputs and schema version `4` catalog state. For fixed-path destinations, that bounded path is the configured backend root.
|
||||
|
||||
## 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.
|
||||
@@ -1,254 +1,84 @@
|
||||
# Application Orchestration
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/app`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/app` owns the top-level application use cases. It coordinates
|
||||
configuration loading, secret resolution, backend construction, source bundle
|
||||
discovery, destination selection, publish planning, publish execution,
|
||||
notification handoff, run reporting, and in-memory run coordination.
|
||||
`internal/app` owns top-level application use cases: run, single-pipeline run, staged-source run, validate, inspect, manifest creation, reconcile-state planning/repair, prune planning/execution, and HTTP upload serving. It coordinates config loading, secret resolution, backend construction, source discovery, destination selection, publish planning/execution, state repair reporting, retention prune reporting, notification handoff, output projection, and upload coordination.
|
||||
|
||||
The package is the boundary between callers and lower-level domain packages. It
|
||||
does not own manifest validation rules, destination state comparison, storage
|
||||
path rules, output planning, transform rendering, or backend-specific behavior.
|
||||
## Inputs And Outputs
|
||||
|
||||
## Use Cases
|
||||
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.
|
||||
|
||||
`Run` is the CLI-facing all-pipeline entrypoint. It accepts a context, optional
|
||||
config path, dry-run flag, force flag, stdout writer, output format, and
|
||||
optional notifier. It runs every configured pipeline, builds a `RunReport`, and
|
||||
projects the report to text or JSON when stdout is supplied.
|
||||
Outputs include `RunReport`, `ReconcileStateReport`, `PrunePlanReport`, `PruneReport`, 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.
|
||||
|
||||
`RunPipeline` is the app-layer single-pipeline entrypoint. It accepts a context,
|
||||
config path, pipeline ID, dry-run flag, force flag, and optional notifier. It
|
||||
loads the same config as `Run`, narrows execution to exactly one configured
|
||||
pipeline, and returns a `RunReport` without writing command output.
|
||||
## Boundaries
|
||||
|
||||
`RunPipelineWithLocalSource` is the app-layer single-pipeline entrypoint for an
|
||||
already prepared local source bundle root. It accepts the same pipeline
|
||||
selection and execution options as `RunPipeline` plus a local source root path.
|
||||
It loads config, selects one configured pipeline, opens the supplied source
|
||||
root as a local backend, validates exactly that root bundle, and then uses the
|
||||
same destination fan-out path as normal runs.
|
||||
`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.
|
||||
|
||||
`Validate` and `Inspect` accept either a local path or one configured pipeline
|
||||
source. They share source backend construction with run workflows and never open
|
||||
destination backends.
|
||||
User-facing command parsing stays in `internal/cli`, including `reconcile-state` and `prune` flag validation and help text. User-facing config reference stays in `docs/config.md`. External contracts live under `docs/integrations/`.
|
||||
|
||||
`Serve` is the CLI-facing HTTP upload server entrypoint. It loads config,
|
||||
loads the configured secrets directory, resolves upload bearer tokens for
|
||||
configured `http_upload` sources, creates an `UploadCoordinator`, binds
|
||||
`server.http.bind`, and serves the upload API until its context is cancelled.
|
||||
## Config Fields Used
|
||||
|
||||
## Run Reports
|
||||
The package consumes the loaded `config.Config`: `server.http`, `secrets.directory`, pipeline ids, source and destination backend fields, validation policy, workflow, publish policy, transform policy, path mapping, links, and retention policy.
|
||||
|
||||
`RunReport` is the structured result model for run workflows. It includes
|
||||
dry-run state, pipeline summaries, action records, output metadata, summary
|
||||
counters, warnings, and destination-scoped output errors.
|
||||
Config fields are validated and defaulted by `internal/config` before app workflows use them.
|
||||
|
||||
Text and JSON run output are projections of `RunReport`. JSON tags on report
|
||||
records match the CLI JSON output contract. Text output preserves the CLI
|
||||
summary shape while keeping output rendering outside the core planning and
|
||||
execution loop.
|
||||
## Adapters Used
|
||||
|
||||
Destination-scoped failures produce a report plus an aggregated error. Fatal
|
||||
setup failures, such as config loading, source open, or source discovery
|
||||
failures, return before a complete run report is available.
|
||||
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.
|
||||
|
||||
## Run Flow
|
||||
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.
|
||||
|
||||
The app runner:
|
||||
## State And Manifest Behavior
|
||||
|
||||
1. loads config from the supplied path or `config.DefaultConfigPath`;
|
||||
2. loads configured secret files into a config-owned environment resolver;
|
||||
3. builds the app-level backend factory and transform registry;
|
||||
4. opens each selected pipeline source backend;
|
||||
5. discovers validated source bundles from the source root;
|
||||
6. selects source bundles for each destination according to path mapping;
|
||||
7. opens destination backends independently;
|
||||
8. builds publish plans for selected bundle and destination combinations;
|
||||
9. records warnings, action records, output metadata, and summary counters;
|
||||
10. executes publish or replacement plans unless dry-run is enabled;
|
||||
11. invokes the notifier after successful publish or replacement actions;
|
||||
12. returns the structured report and any aggregated destination failures.
|
||||
Run workflows discover and validate source bundles through `internal/bundle`. Destination catalog actions are prepared and written through `internal/publish` and `internal/state`; the app layer records report projections of those actions and results. Run summaries count `publish_new`, `upsert_additive`, `replace_catalog`, `skip_same`, `force_replace`, `fail_unmanaged`, and `fail_conflict` separately.
|
||||
|
||||
`RunPipeline` follows the same flow after selecting a single configured
|
||||
pipeline. It uses the same backend factory, secret loading, transform registry,
|
||||
warning generation, destination planning, publish execution, notification
|
||||
behavior, and failure aggregation as `Run`.
|
||||
Reconcile-state workflows load one configured pipeline/destination selector, open that destination root, parse the root `.distributor.json`, and report missing catalog output records plus unmanaged storage entries. Managed output existence checks use storage `Stat`; unmanaged reporting uses bounded storage `Walk` and excludes `.distributor.json` plus all paths already recorded as managed. Apply mode removes missing managed output records from valid catalog state only; dry-run reports the same repair without writing. Text output reports `changed`, `would_change`, or `unchanged`; JSON output uses the shared app envelope. It does not validate output digests, delete destination files, adopt unmanaged files, or rewrite invalid state.
|
||||
|
||||
`RunPipelineWithLocalSource` follows the same flow after pipeline selection
|
||||
except for source opening and source discovery. It opens the supplied local
|
||||
source root directly, validates the root bundle before opening any destinations,
|
||||
and passes the resulting local source backend and bundle into the same
|
||||
destination planning and execution loop. Destination code receives the normal
|
||||
storage backend and bundle values and does not depend on how the source root was
|
||||
prepared.
|
||||
Prune planning consumes parsed catalog state and a validated retention prune policy, then returns owner-scoped managed output records that would be pruned or preserved. Planning uses output `updated_at` timestamps, applies `keep_latest` before `older_than` when both are configured, and does not open storage, delete files, or rewrite state.
|
||||
|
||||
## Upload Coordination
|
||||
Prune execution loads one configured pipeline/destination selector, opens that destination root, parses the root `.distributor.json`, and builds a plan from the destination retention policy. Dry-run returns the same planned and preserved managed output records without deleting files or rewriting state. Apply mode deletes only planned managed output paths, never unmanaged files or `.distributor.json`, then removes confirmed deleted records from state and updates the state timestamp. If a delete fails after earlier deletes succeeded, it rewrites state only for the confirmed deletions and preserves records for the failed and unattempted outputs so a retry remains accurate. Text output reports `changed`, `would_change`, or `unchanged`; JSON output uses the shared app envelope.
|
||||
|
||||
`UploadCoordinator` owns in-memory coordination for asynchronous upload
|
||||
processing. It admits uploads for configured `http_upload` pipelines, generates
|
||||
run IDs, tracks status records, stages accepted archives through
|
||||
`internal/ingest`, and executes the selected pipeline through
|
||||
`RunPipelineWithLocalSource`.
|
||||
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 run IDs use:
|
||||
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.
|
||||
|
||||
```text
|
||||
<pipeline id>.<UTC timestamp>.<random suffix>
|
||||
```
|
||||
## Skip And Resume Behavior
|
||||
|
||||
The timestamp uses `YYYYMMDDThhmmssZ` UTC format and the suffix is filesystem
|
||||
safe.
|
||||
Fan-out destinations are independent. A destination failure is recorded and does not prevent later destinations from being attempted. Run dry-run builds plans and reports without destination writes, destination state writes, notifier calls, or SSH known-host persistence. Reconcile-state dry-run reports missing managed records and unmanaged entries without rewriting state. Prune dry-run reports planned managed output deletes without deleting outputs or rewriting state.
|
||||
|
||||
The coordinator records these statuses:
|
||||
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.
|
||||
|
||||
- `accepted`
|
||||
- `queued`
|
||||
- `running`
|
||||
- `succeeded`
|
||||
- `failed`
|
||||
- `expired`
|
||||
## Failure Behavior
|
||||
|
||||
Admission is bounded by `server.http.queue_size`. Full queues are rejected
|
||||
before the upload body is staged. Execution is bounded by
|
||||
`server.http.max_concurrency`, and only one upload for a given pipeline may run
|
||||
at a time. Later uploads for the same pipeline remain queued until the active
|
||||
run finishes.
|
||||
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.
|
||||
|
||||
Completed records retain the final run report or error text until
|
||||
`server.http.retention` elapses. Expiration removes completed status records and
|
||||
their committed staged bundle directories. The coordinator is memory-only and
|
||||
does not persist queue state, status records, or run reports.
|
||||
Reconcile-state setup fails unless the caller supplies a pipeline id and destination id that select one configured destination root. Catalog repair is scoped to that owner unless `--all-owners` is set. Invalid or unreadable state fails before any rewrite.
|
||||
|
||||
## HTTP Upload Server
|
||||
Prune setup fails unless the caller supplies a pipeline id and destination id that select one configured destination root. Pruning is scoped to the selected owner and preserves unrelated owners. Invalid or unreadable state fails before deletes or rewrites. Delete failures return a report with confirmed deletions and the failed output.
|
||||
|
||||
The HTTP upload server is app-layer transport wiring around
|
||||
`UploadCoordinator`. It owns request authentication, route dispatch, HTTP status
|
||||
mapping, and JSON response projection. Bundle staging and publication remain in
|
||||
the coordinator and staged-source run path.
|
||||
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.
|
||||
|
||||
Server startup resolves every configured `http_upload` source `token_env`
|
||||
through the config-owned environment resolver after `secrets.directory` has
|
||||
been loaded. Startup fails when a token is missing, empty, or duplicates another
|
||||
upload pipeline token. Error messages identify environment variable names and
|
||||
pipeline ids, but not token values.
|
||||
## Tests To Inspect
|
||||
|
||||
Routes:
|
||||
- `internal/app/*_test.go`
|
||||
- `internal/app/prune_test.go`
|
||||
- `internal/cli/reconcile_state_test.go`
|
||||
- `internal/cli/prune_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
- `internal/config/*_test.go`
|
||||
- `internal/ingest/*_test.go`
|
||||
- `internal/publish/*_test.go`
|
||||
|
||||
- `GET /healthz`: returns `200` after config, secrets, tokens, coordinator, and route setup succeed.
|
||||
- `POST /upload`: accepts authenticated tar and tar.gz archives and returns an accepted run id.
|
||||
- `GET /runs/<run_id>`: returns the current in-memory upload status record or `404`.
|
||||
## Architectural Invariants
|
||||
|
||||
The upload token maps to exactly one configured pipeline. Producers do not
|
||||
submit pipeline ids, and submitted `pipeline` or `pipeline_id` query values are
|
||||
rejected. Full queues are rejected before the request body is read. Oversized
|
||||
uploads, unsupported content types, invalid bearer tokens, full queues, and
|
||||
unknown status records are mapped to stable HTTP status codes without returning
|
||||
secret token values.
|
||||
|
||||
## Coordination
|
||||
|
||||
`PipelineRunCoordinator` wraps `RunPipeline` with in-memory admission control.
|
||||
It allows different pipeline IDs to run concurrently and rejects a second active
|
||||
run for the same pipeline ID.
|
||||
|
||||
Coordinator records contain a run ID, pipeline ID, status, timestamps, completed
|
||||
report, and error text when applicable. Active state is memory-only and is
|
||||
cleared after success, failure, unknown pipeline ID, or context cancellation.
|
||||
|
||||
The admission context is checked before a run is accepted. Once accepted, the
|
||||
run uses the coordinator lifetime context, so caller cancellation can stop
|
||||
waiting for admission without owning the actual run lifetime.
|
||||
|
||||
The coordinator does not queue duplicate runs, persist run records, or define
|
||||
transport endpoints.
|
||||
|
||||
## Errors
|
||||
|
||||
`Run` returns immediately for config loading errors, context cancellation before
|
||||
work starts, source open errors, and source discovery errors.
|
||||
|
||||
`RunPipeline` returns `PipelineNotFoundError` when the requested pipeline ID is
|
||||
not configured. Callers can detect that condition with `IsPipelineNotFound`.
|
||||
|
||||
`RunPipelineWithLocalSource` also returns `PipelineNotFoundError` for an unknown
|
||||
pipeline ID. It returns before destination opening when the supplied local
|
||||
source root is missing, cannot be opened, or does not validate as one complete
|
||||
source bundle.
|
||||
|
||||
Per-destination backend, planning, execution, and notification errors are
|
||||
aggregated into one run error after remaining destinations have been attempted.
|
||||
Destination diagnostics include pipeline ID, destination ID, backend, and
|
||||
bundle path.
|
||||
|
||||
`PipelineRunCoordinator` returns `DuplicatePipelineRunError` when the same
|
||||
pipeline already has an active run. Callers can detect that condition with
|
||||
`IsDuplicatePipelineRun`.
|
||||
|
||||
Stdout write errors are returned immediately because the caller's requested
|
||||
output stream can no longer be trusted.
|
||||
|
||||
## Package Layout
|
||||
|
||||
Run helpers are grouped by responsibility:
|
||||
|
||||
- `run.go`: `Run`, `RunPipeline`, and shared run orchestration.
|
||||
- `run_output.go`: `RunReport`, action/output records, and text/JSON report projection.
|
||||
- `run_summary.go`: summary counters.
|
||||
- `run_failures.go`: destination failure aggregation and partial-result detection.
|
||||
- `run_selection.go`: destination bundle selection, path mapping decisions, and fixed-path warnings.
|
||||
- `run_warnings.go`: secret and SSH warning records.
|
||||
- `run_notify.go`: notification event projection and action filtering.
|
||||
- `run_coordinator.go`: in-memory run admission, run IDs, status records, and duplicate-run errors.
|
||||
- `upload_coordinator.go`: in-memory upload admission, queueing, status tracking, staging handoff, and staged-source execution.
|
||||
- `upload_http.go`: HTTP upload authentication, routes, JSON response projection, and HTTP error mapping.
|
||||
- `serve.go`: config/secrets loading and HTTP server startup.
|
||||
- `backends.go`: app-level backend factory wiring.
|
||||
- `transforms.go`: app-level transform registry wiring.
|
||||
- `source_select.go`: configured-source selection shared by `validate` and `inspect`.
|
||||
|
||||
## Backend And Transform Wiring
|
||||
|
||||
The app-level backend factory registers local, SSH, and S3 backends for runtime
|
||||
execution. Source and destination backend config is converted through a shared
|
||||
app-local open spec before adapter construction.
|
||||
|
||||
Credential references are resolved through the config environment resolver.
|
||||
Production app code must not read backend credential environment variables
|
||||
directly.
|
||||
|
||||
The app-level transform registry registers Markdown-to-HTML through
|
||||
`internal/transform/markdown`. Lower-level publish code receives a resolver and
|
||||
does not import concrete transform implementations.
|
||||
|
||||
## Dry-Run Behavior
|
||||
|
||||
Dry-run 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 invoke notifications.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing app orchestration, inspect tests under:
|
||||
|
||||
- `internal/app`
|
||||
- `internal/cli`
|
||||
- `internal/publish`
|
||||
|
||||
Use focused app tests for report structure, single-pipeline execution,
|
||||
coordinator admission, warning generation, notification behavior, and
|
||||
partial-result aggregation.
|
||||
|
||||
## Invariants
|
||||
|
||||
- One source fans out to each destination independently.
|
||||
- Destination failures do not prevent later destinations from being planned.
|
||||
- Destination-scoped failures still produce a structured report plus an aggregated error.
|
||||
- Dry-run must not mutate destination storage or invoke notifications.
|
||||
- `RunPipeline` must use the same run path as `Run` after pipeline selection.
|
||||
- Duplicate in-flight runs are rejected only for the same pipeline ID.
|
||||
- Different pipeline IDs may run concurrently.
|
||||
- Concrete backend and transform registration stays at the app layer.
|
||||
- The default notifier is `notify.Noop`.
|
||||
- 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.
|
||||
- Reconcile-state repairs state records only; it never deletes or adopts destination files.
|
||||
- Prune execution deletes managed output paths only and preserves failed records for retry.
|
||||
|
||||
@@ -1,55 +1,53 @@
|
||||
# Bundles
|
||||
# Source Bundle Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/bundle`.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- Source file paths must stay relative to the bundle root.
|
||||
- The top-level bundle digest is derived from manifest file records in order.
|
||||
- Discovery order is lexical and deterministic.
|
||||
- Nested manifests are rejected.
|
||||
## Adapters Used
|
||||
|
||||
The package depends only on `internal/storage.Backend`. Concrete local, SSH/SFTP, S3-compatible, and fake backends are hidden behind that interface.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,101 +1,57 @@
|
||||
# Configuration Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/config`.
|
||||
|
||||
## 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 `server`, 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. App entrypoints load the configured
|
||||
secrets directory after config validation and before credential-consuming work.
|
||||
## Config Fields Used
|
||||
|
||||
## Defaults
|
||||
The package defines all user-visible config fields: `server.http`, `secrets`, `pipelines`, source and destination backend fields, validation policy, destination workflow, publish policy, transform policy, path mapping, links, and retention policy.
|
||||
|
||||
Defaults are applied in `ApplyDefaults`:
|
||||
## Adapters Used
|
||||
|
||||
- HTTP server `bind` defaults to `127.0.0.1:8080`;
|
||||
- HTTP server `staging_root` defaults to `/var/spool/distributor`;
|
||||
- HTTP server `max_upload_size` defaults to `20MB`;
|
||||
- HTTP server `queue_size` defaults to `16`;
|
||||
- HTTP server `max_concurrency` defaults to `1`;
|
||||
- HTTP server `retention` defaults to `24h`;
|
||||
- `http_upload` source `staging_path` defaults to `<server.http.staging_root>/<pipeline id>`;
|
||||
- `http_upload` source `max_upload_size` defaults to `server.http.max_upload_size`;
|
||||
- pipeline validation defaults `on_digest_mismatch` to `fail`;
|
||||
- SSH backend `port` defaults to `22`;
|
||||
- SSH backend `host_key_policy` defaults to `accept-new`;
|
||||
- destination publish policy defaults to source output only;
|
||||
- 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`.
|
||||
No external storage adapters are used directly. The package exposes normalized config and credential values consumed by app-level adapter construction.
|
||||
|
||||
## Validation responsibilities
|
||||
## State And Manifest Behavior
|
||||
|
||||
Validation requires positive HTTP server limits and retention, 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 workflow, publish/transform combinations, links, retention policy, backend roots, S3 prefix shape, and HTTP upload source settings. Legacy destination policy YAML fields are rejected by strict decoding because they are not user-visible config fields.
|
||||
|
||||
HTTP upload sources require `token_env`, a staging path after defaults, and a positive maximum upload size. Literal token fields are not part of the YAML schema. The `http_upload` backend is accepted only for sources and rejected for destinations.
|
||||
## Skip And Resume Behavior
|
||||
|
||||
Upload size values are parsed from strings with `B`, `KB`, `MB`, or `GB` suffixes using 1024 multipliers. Retention values are parsed with `time.ParseDuration`. Explicit zero values fail validation; omitted values receive defaults before validation.
|
||||
The package has no runtime skip or resume behavior. Publish planning later applies workflow values and per-run force options to destination catalog outcomes.
|
||||
|
||||
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.
|
||||
## Failure 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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## Executable support boundary
|
||||
|
||||
Config validation accepts `local`, `ssh`, `s3`, and source-only `http_upload` backend shapes. Runtime `run`, `validate`, and `inspect` workflows open `local`, `ssh`, and `s3` through `internal/app`. Runtime `serve` workflows execute `http_upload` sources through the app upload coordinator and HTTP server.
|
||||
|
||||
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`.
|
||||
|
||||
HTTP upload config is source-only. Config owns its YAML shape, defaulting, size and duration parsing, and validation. The config package does not authenticate requests, stage uploads, or execute HTTP upload sources. The app layer resolves `token_env` through the config-owned environment resolver before starting the HTTP server.
|
||||
|
||||
## 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:
|
||||
## Tests To Inspect
|
||||
|
||||
- `internal/config/load_test.go`
|
||||
- `internal/config/validate_test.go`
|
||||
- example-loading coverage in `internal/config`
|
||||
- user-facing examples under `examples/`
|
||||
- `internal/config/secrets_test.go`
|
||||
- `internal/config/backend_view_test.go`
|
||||
- `internal/app/runtime_test.go`
|
||||
- example configs under `examples/`
|
||||
|
||||
## Invariants
|
||||
## Architectural Invariants
|
||||
|
||||
- Defaults are applied before validation.
|
||||
- Unknown YAML fields are rejected.
|
||||
- `docs/config.md` remains the canonical user-facing config reference.
|
||||
- Runtime backend execution support is not inferred from config validation support.
|
||||
- New user-visible config behavior must be covered by tests and docs in the same change.
|
||||
- `http_upload` is source-only config.
|
||||
- Credential-consuming runtime code must use the config-owned environment resolver.
|
||||
- Secret values are never printed by config warnings.
|
||||
- New user-visible config behavior must update `docs/config.md` and tests.
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
# Ingestion Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/ingest`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/ingest` stages uploaded source bundle archives into local per-run directories. It does not authenticate requests, manage upload queues, publish destinations, or start an HTTP server.
|
||||
`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.
|
||||
|
||||
## Archive staging
|
||||
## Inputs And Outputs
|
||||
|
||||
`StageArchive` accepts one upload body, content type, pipeline staging path, run id, and explicit size and file-count limits. It writes the request body to temporary storage while enforcing the configured upload size limit, extracts the archive into temporary local storage, validates the extracted source bundle, and then commits the validated bundle to:
|
||||
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.
|
||||
|
||||
```text
|
||||
<pipeline staging path>/<run id>
|
||||
```
|
||||
## Boundaries
|
||||
|
||||
The returned `StagedBundle.Root` is a local filesystem path to the validated source bundle root.
|
||||
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`.
|
||||
|
||||
## Accepted archive formats
|
||||
The HTTP API contract is documented in `docs/integrations/http-upload.md`.
|
||||
|
||||
The package accepts only:
|
||||
## Config Fields Used
|
||||
|
||||
- `application/x-tar`
|
||||
- `application/gzip`
|
||||
- `application/x-gzip`
|
||||
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.
|
||||
|
||||
Gzip uploads must contain a tar archive.
|
||||
## Adapters Used
|
||||
|
||||
## Extraction rules
|
||||
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.
|
||||
|
||||
Archive entry paths must be clean relative slash-separated paths. Extraction rejects absolute paths, path traversal, backslash paths, duplicate files, symlinks, hardlinks, devices, sockets, and other special entries.
|
||||
## State And Manifest Behavior
|
||||
|
||||
The archive must contain exactly one root-level `manifest.json`. Nested manifests are rejected.
|
||||
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.
|
||||
|
||||
Regular files and directories are the only accepted tar entries. Regular file extraction enforces the explicit maximum extracted byte count and maximum file count supplied by the caller.
|
||||
## Skip And Resume Behavior
|
||||
|
||||
## Bundle validation
|
||||
The package has no resume behavior. A successful call commits one complete staged bundle root. Failed calls remove temporary data created by that call.
|
||||
|
||||
After extraction, the package loads and validates the staged bundle through `pkg/bundle`. Manifest parsing, source path validation, file existence checks, regular-file checks, file sizes, file SHA-256 digests, and bundle digest validation use the existing source bundle contract.
|
||||
## Failure Behavior
|
||||
|
||||
Validation happens before the staged bundle is committed to its final per-run path.
|
||||
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.
|
||||
|
||||
## Failure behavior
|
||||
## Tests To Inspect
|
||||
|
||||
Failed staging removes temporary archive and extraction data created by the package. A failed call does not publish anything and does not leave a committed per-run bundle directory.
|
||||
- `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.
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
# Link URL Policy
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/link`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Validation behavior
|
||||
|
||||
Accepted URLs must parse successfully, use `http` or `https`, include a host, and omit query strings and fragments.
|
||||
|
||||
## 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.
|
||||
- URL path construction remains in `internal/publish`.
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
# Notify
|
||||
# Notification Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/notify` or app notification wiring.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## 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/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.
|
||||
- Skipped and failed destinations never notify.
|
||||
- The default app notifier is `notify.Noop`.
|
||||
|
||||
@@ -1,52 +1,80 @@
|
||||
# Publish
|
||||
# Publish Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/publish`.
|
||||
|
||||
## 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 output selection, URL planning, catalog action selection, managed cleanup selection, forced 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, workflow, transformer resolver, distributor version, force flag, and request time.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
Output from planning is a `Plan` with action, reason, destination identity, selected outputs, workflow, owner scope, optional existing catalog state, optional superseded legacy marker, optional primary URL, catalog outputs to write, catalog outputs to retain, catalog outputs to delete, force metadata, and clear-root metadata. Execution writes selected source outputs, generated outputs, and schema version `4` `.distributor.json` for executable catalog actions.
|
||||
|
||||
## 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, prune retained outputs, 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 `workflow`, `publish`, `transform`, `links`, 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` and maps catalog conditions into actions:
|
||||
|
||||
Before changing publish behavior, inspect tests under `internal/publish` and run tests under `internal/app`.
|
||||
- `publish_new`: no valid state exists and the destination bundle path is empty.
|
||||
- `upsert_additive`: additive workflow writes planned outputs and retains unrelated catalog outputs.
|
||||
- `replace_catalog`: replacement workflow writes planned outputs and deletes omitted outputs for the current owner.
|
||||
- `skip_same`: valid catalog metadata already matches every planned output.
|
||||
- `force_replace`: explicit bounded replacement selected by `Force`.
|
||||
- `fail_unmanaged`: unmanaged destination content blocks publication.
|
||||
- `fail_conflict`: invalid or unsupported state blocks publication.
|
||||
|
||||
## Invariants
|
||||
Superseded legacy state is identified by schema number and planned through catalog output projection. A successful publish writes schema version `4` catalog state.
|
||||
|
||||
- Publish planning is deterministic for the same source, destination state, policies, and transform outputs.
|
||||
- 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.
|
||||
- Normal replacement deletes only managed paths recorded in existing state plus `.distributor.json`.
|
||||
- Forced replacement is explicit per run and deletes only within the destination bundle path.
|
||||
- Publish execution writes destination state after selected outputs are written.
|
||||
- Transform implementations are resolved through an interface supplied by the caller.
|
||||
- Unmanaged destination content is overwritten only by explicit forced replacement.
|
||||
Execution writes destination state after selected outputs are written. Catalog output records include owner identity, compact source identity, copied source output metadata, generated output metadata, output timestamps, and optional URL metadata.
|
||||
|
||||
## Workflow Behavior
|
||||
|
||||
Additive workflow computes a write set for the planned outputs and preserves catalog outputs for unplanned paths. Existing catalog records for planned paths are replaced by the current owner and source identity. When every planned output already matches catalog metadata, planning returns `skip_same` and ignores unrelated retained catalog outputs for the no-op decision.
|
||||
|
||||
Replacement workflow computes a write set for the planned outputs, preserves other-owner outputs, and deletes omitted outputs owned by the current pipeline and destination. It does not need `Force`. Matching planned outputs return `skip_same` only when replacement would not delete omitted outputs for the current owner.
|
||||
|
||||
Catalog skip comparison is metadata-only. It checks pipeline id, destination id, source id, source digest, source creation timestamp, output path, kind, digest, size, generated output source path, generated output transform, and output URL metadata. It does not read destination file bytes, and `skip_same` execution does not write outputs, rewrite `.distributor.json`, delete files, or notify.
|
||||
|
||||
Forced replacement is explicit per request. It deletes the bounded destination bundle path before writing planned outputs and schema version `4` catalog state. Catalog planning selects `force_replace` only when `Force` is true and normal planning would otherwise fail for a non-empty no-state destination, a planned path collision with unmanaged storage content, invalid destination state, or unsupported future destination state.
|
||||
|
||||
Retention pruning is not part of publish execution and does not run automatically after a successful publish. The app-level prune workflow uses destination state after publication to select managed outputs for deletion.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Planning fails for incomplete requests, invalid publish/transform policy, invalid workflow, output path collisions, unresolved transforms, invalid Markdown output selection, invalid link URL planning, invalid destination state without force, unmanaged destination content without force, and unsupported future state without force.
|
||||
|
||||
Execution fails on delete, read, transform output, write, state validation, state serialization, or context errors. Execution refuses actions that are not executable catalog 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.
|
||||
- Additive workflow never adopts unmanaged storage content.
|
||||
- Replacement workflow deletes only catalog outputs owned by the current pipeline and destination unless force replacement clears the bounded destination bundle path.
|
||||
- Forced replacement deletes only within the supplied destination bundle path and then writes catalog state using the same output projection as normal catalog publish planning.
|
||||
- Destination state is written after selected outputs are written.
|
||||
- Transform resolution stays behind a caller-supplied interface.
|
||||
- Unmanaged content is claimed only by explicit force.
|
||||
|
||||
@@ -1,55 +1,73 @@
|
||||
# Destination State
|
||||
# Destination State Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/state`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/state` parses, validates, writes, and compares `.distributor.json` destination state.
|
||||
`internal/state` parses, validates, serializes, and updates `.distributor.json` destination catalog state records.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
Inputs are destination state JSON, constructed catalog values, owner scopes, managed output paths, timestamps, and prune policy inputs. Outputs are validated catalog values, JSON bytes, managed path lists, owner-filtered output lists, missing-output repair results, and prune candidate plans.
|
||||
|
||||
## 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 workflow actions, build publish outputs, generate URLs, or parse config. Publish planning consumes parsed catalog state and catalog output helpers.
|
||||
|
||||
## 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
|
||||
`internal/state` uses shared constants for catalog mode, output kinds, slug-like id validation, link validation, storage path validation, and source manifest validation. Destination ids, pipeline ids, and link URLs originate from config but are supplied as values by callers.
|
||||
|
||||
## Adapters Used
|
||||
|
||||
None.
|
||||
|
||||
## State And Manifest Behavior
|
||||
|
||||
Current `.distributor.json` publish output uses schema version `4` catalog state. Required top-level fields are `schema_version`, `created_at`, `updated_at`, `state.mode`, and `outputs`; `distributor_version` is optional.
|
||||
|
||||
Each catalog output record requires a clean path, pipeline id, destination id, source identity, `source` or `generated` kind, lowercase SHA-256 digest, non-negative size, and created/updated timestamps. Generated outputs require `source_path` and `transform`; copied source outputs must omit both. Stored URLs are optional and must pass `internal/link` validation.
|
||||
|
||||
Embedded source identity records contain source manifest id, digest, and creation timestamp. Full source manifests are not embedded in catalog state.
|
||||
|
||||
The package identifies schema versions older than the current catalog schema as superseded legacy state for publish planning. It rejects invalid JSON, malformed catalog state, and unsupported future schema versions.
|
||||
|
||||
The package provides helpers for finding catalog outputs by path, filtering outputs by owner, listing managed output paths, removing missing output records for one owner or every owner, and building owner-scoped prune candidates.
|
||||
|
||||
Publish execution owns catalog output projection and timestamp preservation for rewritten outputs. State helpers only parse, validate, filter, and remove catalog records supplied by callers.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
Catalog parsing and helper transformations are pure. State code does not decide whether to skip, upsert, replace, force, or fail; publish planning maps parsed state and storage observations to actions.
|
||||
|
||||
Missing-output removal helpers remove matching output records only and leave storage inspection, timestamp updates, validation, and state rewrites to callers.
|
||||
|
||||
Prune planning helpers are pure. They select managed output candidates, sort deterministically by `updated_at` and path, preserve the newest `keep_latest` candidates before evaluating `older_than`, and return planned prune/preserve lists without mutating state. App-level prune execution uses missing-output removal helpers to remove only confirmed deleted records after storage deletion succeeds.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Parsing rejects invalid JSON, trailing data, missing required fields, invalid timestamps, invalid catalog mode, duplicate outputs, invalid output paths, unsupported output kinds, missing generated transform metadata, invalid URLs, invalid digests, and negative sizes.
|
||||
|
||||
## Tests To Inspect
|
||||
|
||||
- `internal/state/catalog_test.go`
|
||||
- `internal/state/prune_test.go`
|
||||
- `internal/app/reconcile_state_test.go`
|
||||
- `internal/cli/reconcile_state_test.go`
|
||||
- `internal/publish/*_test.go`
|
||||
|
||||
## Architectural Invariants
|
||||
|
||||
- `.distributor.json` is the destination sentinel and state record.
|
||||
- Embedded source manifests use the same validation rules as source bundles.
|
||||
- Generated outputs always record a transform id.
|
||||
- State helpers do not inspect or mutate storage.
|
||||
- Source identity uses the source bundle contract.
|
||||
- Newly written publish state uses schema version `4`.
|
||||
- Superseded legacy schema handling is limited to identifying older state for publish planning.
|
||||
- Missing-output repair helpers preserve unrelated owner records and outputs.
|
||||
- Prune planning uses output `updated_at` and preserves unrelated owners.
|
||||
- Generated outputs always record a transform id and source path.
|
||||
- Output records always carry created and updated timestamps after parsing.
|
||||
- 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.
|
||||
|
||||
@@ -1,75 +1,59 @@
|
||||
# Storage
|
||||
# Storage Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/storage`, storage adapters, or storage-backed callers.
|
||||
|
||||
## 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.
|
||||
|
||||
Entries report a logical path, type, and size when available. Entry types are `file`, `directory`, `symlink`, and `other`.
|
||||
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.
|
||||
|
||||
## 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`, `ManagedOutputTargets`, 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:
|
||||
Reconcile-state callers use `Stat` to check whether managed output paths still exist and bounded recursive `Walk` to report unmanaged entries under a selected destination root. Prune execution callers use `DeleteManagedOutputs` for selected managed output records so `.distributor.json` is not part of the deletion target set. Storage does not decide whether entries are managed; callers compare entries against destination state.
|
||||
|
||||
- context cancellation is checked before callback emission;
|
||||
- `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.
|
||||
## Skip And Resume Behavior
|
||||
|
||||
`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.
|
||||
Storage has no publication skip policy. It supplies `HasAny` for unmanaged-content checks, `Stat` and `Walk` for state repair inspection, `DeleteManagedOutputs` target construction for managed output cleanup, `DeleteManagedBundle` target construction for normal replacement cleanup, and `DeletePrefix` semantics for explicit forced replacement.
|
||||
|
||||
## Deletion
|
||||
## Failure Behavior
|
||||
|
||||
`DeleteManagedBundle` may delete listed managed outputs plus `.distributor.json`.
|
||||
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`.
|
||||
|
||||
`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.
|
||||
## Tests To Inspect
|
||||
|
||||
## Local, SSH, S3, and fake backends
|
||||
- `internal/storage/*_test.go`
|
||||
- `internal/storage/fake/*_test.go`
|
||||
- `internal/app/reconcile_state_test.go`
|
||||
- `internal/adapters/local/*_test.go`
|
||||
- `internal/adapters/ssh/*_test.go`
|
||||
- `internal/adapters/s3/*_test.go`
|
||||
|
||||
The local adapter maps logical paths to a configured filesystem root and keeps adapter-specific path handling behind the storage interface.
|
||||
## Architectural Invariants
|
||||
|
||||
The SSH adapter maps logical paths to a configured remote SFTP root. It uses native SSH and SFTP libraries, supports SSH agent and key-file authentication, applies host-key policies, rejects unsafe logical paths, reports symlink entries from `Lstat`, and limits deletion to managed targets or explicit bounded prefixes.
|
||||
|
||||
The S3 adapter maps logical paths to object keys below a configured bucket and optional prefix. It uses the AWS SDK for Go v2, treats prefixes as object trees, requires exact objects for `Stat`, paginates traversal, applies conservative overwrite checks with `HeadObject`, infers basic content types, and limits deletion to managed target objects or explicit bounded object-key prefixes.
|
||||
|
||||
The fake backend is an in-memory implementation for package tests. It is not registered for runtime use.
|
||||
|
||||
## Tests
|
||||
|
||||
Before changing storage behavior, inspect tests under:
|
||||
|
||||
- `internal/storage`
|
||||
- `internal/storage/fake`
|
||||
- `internal/adapters/local`
|
||||
- `internal/adapters/ssh`
|
||||
- `internal/adapters/s3`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Core packages depend on `internal/storage`, not concrete adapters.
|
||||
- Logical paths are slash-separated and confined to the backend root.
|
||||
- `storage.List` uses backend traversal and returns deterministic entries.
|
||||
- Managed deletion is limited to recorded outputs plus `.distributor.json`.
|
||||
- Prefix deletion is limited to the requested logical prefix.
|
||||
- Runtime backend registration is owned by `internal/app`.
|
||||
- Logical paths are clean relative slash-separated paths confined to the backend root.
|
||||
- Core packages never import concrete adapters.
|
||||
- `storage.List` returns deterministic sorted entries.
|
||||
- Managed output deletion targets are recorded outputs only; managed bundle deletion targets are recorded outputs plus `.distributor.json`.
|
||||
- Prefix deletion is bounded to the requested logical prefix.
|
||||
- Runtime registration remains app-owned.
|
||||
|
||||
@@ -1,47 +1,52 @@
|
||||
# Transform
|
||||
# Transform Internals
|
||||
|
||||
Audience: developers and LLM coding agents changing `internal/transform` or transform implementations.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## 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`
|
||||
- `internal/transform/markdown`
|
||||
## Adapters Used
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
- Non-Markdown source files do not generate Markdown outputs.
|
||||
- Non-Markdown source files do not generate sidecar outputs.
|
||||
- Transform registration stays outside publish planning.
|
||||
|
||||
@@ -1,107 +1,179 @@
|
||||
# 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
|
||||
|
||||
Validate a source bundle:
|
||||
Validate a producer bundle before publishing:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate examples/source-bundle
|
||||
```
|
||||
|
||||
Preview a local publication:
|
||||
Preview a configured run before writing destination content:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
|
||||
```
|
||||
|
||||
Run the local publication:
|
||||
Publish after reviewing the preview:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-publish.yml
|
||||
```
|
||||
|
||||
Run the local HTML publication:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config examples/local-html.yml
|
||||
```
|
||||
|
||||
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:
|
||||
Use JSON output for automation:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```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:
|
||||
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 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
|
||||
```
|
||||
|
||||
## HTTP Upload Workflow
|
||||
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.
|
||||
|
||||
`distributor serve` runs the HTTP upload API for pipelines whose source backend
|
||||
is `http_upload`. Each upload token maps to one configured pipeline, and each
|
||||
accepted archive is staged, validated, and published through the same
|
||||
destination fan-out path used by local source runs.
|
||||
## Filesystem And Storage Layout
|
||||
|
||||
Minimal local HTTP upload configuration:
|
||||
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.
|
||||
|
||||
```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:
|
||||
- id: reports
|
||||
source:
|
||||
backend: http_upload
|
||||
token_env: DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /srv/reports/archive
|
||||
Each destination has its own backend root:
|
||||
|
||||
- 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`.
|
||||
|
||||
Destination path mapping controls where each source bundle is published beneath the destination root:
|
||||
|
||||
- `preserve_relative` publishes each source bundle at the same source-root-relative path.
|
||||
- `fixed` publishes one selected source bundle at the destination 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.
|
||||
|
||||
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 catalog. It records catalog schema version `4`, output owners, source identity for each output, output digests and sizes, timestamps, and optional URL metadata.
|
||||
|
||||
`manifest.json` from the source bundle is not copied as destination state.
|
||||
|
||||
## Catalog Publish Behavior
|
||||
|
||||
`distributor` plans from the current source bundle, destination workflow, destination storage content, and `.distributor.json`.
|
||||
|
||||
- No valid state and no destination content: publish new outputs.
|
||||
- No valid state and existing destination content: fail as unmanaged unless `--force` is used.
|
||||
- Valid catalog state with `workflow: additive`: write planned outputs and retain unrelated catalog outputs.
|
||||
- Valid catalog state with `workflow: replacement`: write planned outputs and remove omitted outputs owned by the selected pipeline and destination.
|
||||
- Planned output path exists in storage but is not recorded in valid catalog state - fail as unmanaged unless `--force` is used.
|
||||
- Invalid destination state or unsupported future state - fail as conflict unless `--force` is used.
|
||||
- Superseded legacy state - publish through the catalog planner and write schema version `4` state on success.
|
||||
|
||||
`workflow: additive` is the default. It is useful for archive roots, fan-out roots that intentionally receive disjoint outputs, and roots that accumulate managed outputs over time.
|
||||
|
||||
`workflow: replacement` is useful for stable latest-style roots where the current pipeline and destination should leave only the currently planned output set for that owner. Replacement workflow is normal managed behavior and does not require `--force`.
|
||||
|
||||
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.
|
||||
|
||||
If a write fails after some outputs were written, `distributor` attempts cleanup before returning the error. Operators should still inspect the destination bundle path after a failed write before retrying.
|
||||
|
||||
## Destination State Repair
|
||||
|
||||
Use `reconcile-state` when `.distributor.json` still records managed outputs that no longer exist in destination storage. This repairs the catalog record only; it does not restore missing files.
|
||||
|
||||
Preview the repair first:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor reconcile-state \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--destination <destination-id> \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Create `/run/secrets/distributor/DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN` or set the
|
||||
real process environment variable before starting the server. Distributor does
|
||||
not read literal upload tokens from YAML.
|
||||
Apply after reviewing the report:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor reconcile-state \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--destination <destination-id>
|
||||
```
|
||||
|
||||
The command opens the configured destination root selected by `--pipeline` and `--destination`, reads the root `.distributor.json`, checks matching catalog output paths with storage metadata, reports missing managed outputs, and reports unmanaged entries under that root. It excludes `.distributor.json` and already managed paths from unmanaged reporting.
|
||||
|
||||
Without `--dry-run`, it removes missing managed output records from valid catalog state and rewrites `.distributor.json`. It never deletes destination files, adopts unmanaged files, validates output digests, or rewrites invalid state. Add `--all-owners` only when every catalog owner inside the selected root should be repaired.
|
||||
|
||||
## Managed Output Pruning
|
||||
|
||||
Use `prune` when a destination config has `retention.prune.enabled: true` and old managed outputs should be removed according to that configured policy. Pruning is never automatic after publish.
|
||||
|
||||
Preview selected managed outputs first:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor prune \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--destination <destination-id> \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Apply after reviewing the report:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor prune \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--destination <destination-id> \
|
||||
--apply
|
||||
```
|
||||
|
||||
The command opens the configured destination root selected by `--pipeline` and `--destination`, reads the root `.distributor.json`, and plans from the selected destination's `retention.prune` policy. It uses managed output `updated_at` timestamps. When both `keep_latest` and `older_than` are configured, it preserves the newest `keep_latest` outputs before applying the age policy.
|
||||
|
||||
`--dry-run` does not delete outputs or rewrite state. `--apply` deletes only planned managed output paths for the selected pipeline/destination owner, preserves unmanaged files, preserves `.distributor.json`, removes confirmed deleted records from state, and updates the state timestamp. If a delete fails after earlier deletes succeed, state is rewritten only for confirmed deletions; failed and unattempted output records remain so retry remains accurate.
|
||||
|
||||
## Dry Runs And Output Review
|
||||
|
||||
`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`, notifier events, or SSH `known_hosts` entries.
|
||||
|
||||
Review these action labels before publishing:
|
||||
|
||||
- `publish_new`: destination state is absent and the destination bundle path is empty.
|
||||
- `upsert_additive`: additive workflow will write planned outputs into valid catalog state.
|
||||
- `replace_catalog`: replacement workflow will write planned outputs and remove omitted outputs for the current owner.
|
||||
- `skip_same`: planned outputs already match valid catalog metadata, so publication will not write outputs, rewrite `.distributor.json`, delete files, or notify.
|
||||
- `force_replace`: destructive catalog replacement selected because `--force` is present for unmanaged content, a planned unmanaged path collision, invalid state, or unsupported future state.
|
||||
- `fail_unmanaged`: unmanaged destination content prevents publication.
|
||||
- `fail_conflict`: invalid or unsupported destination state prevents publication.
|
||||
- `error`: setup, planning, or execution failed for that destination.
|
||||
|
||||
Text and JSON summaries count `publish_new`, `upsert_additive`, `replace_catalog`, `skip_same`, `force_replace`, `fail_unmanaged`, `fail_conflict`, and failed destinations separately. 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.
|
||||
|
||||
The `skip_same` optimization trusts valid catalog metadata. It compares owner identity, source identity, output path, kind, digest, size, generated output metadata, and URL metadata recorded in `.distributor.json`; it does not read destination file bytes to detect bitrot.
|
||||
|
||||
Fixed destinations add fixed-path warnings during dry runs, including the selected source bundle and replacement warnings when the destination root would be replaced. For fixed destinations, the resolved destination bundle path is the backend root.
|
||||
|
||||
## Forced Replacement Workflow
|
||||
|
||||
Use `--force` only after a dry run shows the intended bounded `force_replace` action:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --force
|
||||
go run ./cmd/distributor run --config <config-path> --force
|
||||
```
|
||||
|
||||
Forced replacement can claim a non-empty destination path with no valid `.distributor.json`, replace planned output paths that collide with storage content not recorded in valid catalog state, and recover from invalid or unsupported future destination state. It is reserved for exceptional destructive replacement. Valid catalog-managed additive upserts and replacement workflow publishes do not require `--force`.
|
||||
|
||||
Forced replacement deletes the current destination bundle path before writing planned outputs and schema version `4` catalog 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 configured backend root, so a forced replacement can clear that configured root. Dry-run text and JSON output report that root as `target=.` or `destination_path: "."`.
|
||||
|
||||
`--force` applies only to the current invocation. There is no config field that enables forced replacement by default.
|
||||
|
||||
## HTTP Upload Operation
|
||||
|
||||
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.
|
||||
|
||||
Start the maintained local example:
|
||||
|
||||
@@ -110,305 +182,98 @@ DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN=<token> \
|
||||
go run ./cmd/distributor serve --config examples/http-upload-local.yml
|
||||
```
|
||||
|
||||
Submit a tar or tar.gz source bundle:
|
||||
|
||||
```sh
|
||||
curl -X POST http://127.0.0.1:8080/upload \
|
||||
-H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
|
||||
-H "Content-Type: application/gzip" \
|
||||
--data-binary @bundle.tar.gz
|
||||
```
|
||||
|
||||
Successful admission returns a run id:
|
||||
|
||||
```json
|
||||
{"run_id":"reports.20260603T120000Z.abcdef12","status":"accepted"}
|
||||
```
|
||||
|
||||
Poll status until it reaches `succeeded` or `failed`:
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:8080/runs/<run-id>
|
||||
```
|
||||
|
||||
The status record includes the completed run report on successful publication
|
||||
or error details on failure. Status is memory-only and expires after
|
||||
`server.http.retention`; completed staged bundle directories are removed on
|
||||
expiry. Restarting the process clears upload status and queue state.
|
||||
|
||||
Use `GET /healthz` for readiness after config and tokens load:
|
||||
Readiness:
|
||||
|
||||
```sh
|
||||
curl http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
The default bind address is private loopback. Put TLS, public routing,
|
||||
rate-limiting, and external access policy in a reverse proxy or deployment
|
||||
layer.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
Source bundles are discovered beneath the configured source root. Each bundle is a directory containing `manifest.json`.
|
||||
|
||||
Destination bundle paths are configured per destination with `path_mapping.mode`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
The maintained local examples write under `workspace/`, which is ignored by Git.
|
||||
|
||||
SSH backends use the configured remote `path` as the backend root. Source bundle discovery and destination bundle paths are relative to that root, using the same logical path rules as local storage.
|
||||
|
||||
S3 backends use the configured bucket plus optional `prefix` as the backend root. Source bundle discovery and destination bundle paths are relative to that object-key prefix. Prefixes are object-key prefixes, not real directories.
|
||||
|
||||
## Destination State
|
||||
|
||||
Each published destination bundle contains `.distributor.json`. This file is the managed sentinel and destination state record. It stores:
|
||||
|
||||
- pipeline and destination identity;
|
||||
- publication timestamp;
|
||||
- source manifest used for publication;
|
||||
- copied source output metadata;
|
||||
- generated output metadata;
|
||||
- optional public URL metadata when destination links are configured.
|
||||
|
||||
`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.
|
||||
|
||||
## Go Producer Bundles
|
||||
|
||||
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.
|
||||
|
||||
Minimal producer-side bundle creation:
|
||||
|
||||
```go
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Shell producers can create the same manifest through the CLI after writing bundle files:
|
||||
Upload one tar or tar.gz source bundle archive:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor manifest create <bundle-path> --id reports.example.2026-05-30
|
||||
go run ./cmd/distributor validate <bundle-path>
|
||||
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
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Static HTML Publication
|
||||
|
||||
Markdown-to-HTML publication can write sidecar files or a fixed `index.html`.
|
||||
|
||||
Use sidecar mode when each Markdown source should keep a matching HTML filename:
|
||||
|
||||
```yaml
|
||||
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:
|
||||
For safe producer retries, include an idempotency key that is stable for the same producer run and different for each distinct run:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
|
||||
go run ./cmd/distributor inspect --config <config-path> --pipeline <pipeline-id>
|
||||
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
|
||||
```
|
||||
|
||||
Use `--bundle <path>` to validate or inspect one source-root-relative bundle directory:
|
||||
Go producer applications can use `pkg/upload` instead of constructing archives and HTTP requests directly. See [Upstream Producer Integration](consumers/api.md).
|
||||
|
||||
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`.
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--bundle daily/2026-06-01
|
||||
go run ./examples/upload-client
|
||||
```
|
||||
|
||||
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`.
|
||||
Accepted uploads return after the archive is staged and validated:
|
||||
|
||||
## Dry Runs
|
||||
```json
|
||||
{"run_id":"example-http-upload.20260604T120000Z.abcdef12","status":"accepted"}
|
||||
```
|
||||
|
||||
`--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:
|
||||
Poll status while the in-memory record is retained:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --force
|
||||
go run ./cmd/distributor run --config <config-path> --force
|
||||
curl http://127.0.0.1:8080/runs/<run-id>
|
||||
```
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Failure Handling
|
||||
`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 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.
|
||||
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.
|
||||
|
||||
Errors include the pipeline id, destination id, destination backend, and bundle path where applicable.
|
||||
The default bind address is private loopback. Put TLS, public routing, rate limiting, and external access policy in a reverse proxy or deployment layer.
|
||||
|
||||
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.
|
||||
## Remote Backend Notes
|
||||
|
||||
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.
|
||||
### SSH/SFTP
|
||||
|
||||
After a successful publish or replacement, the internal notifier hook runs. The current default notifier is a no-op. Skipped destinations do not invoke it.
|
||||
|
||||
## SSH Operation Notes
|
||||
|
||||
SSH execution uses SFTP over `golang.org/x/crypto/ssh` and `github.com/pkg/sftp`. It does not shell out to `ssh`, `scp`, or `rsync`.
|
||||
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.
|
||||
|
||||
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 managed 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 and then writes schema version `4` catalog state. 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
|
||||
secrets:
|
||||
directory: /run/secrets/distributor
|
||||
```
|
||||
## Cleanup And Recovery
|
||||
|
||||
The 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.
|
||||
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 invalid or unsupported destination state, inspect `.distributor.json`; use `--force` only after dry-run review confirms bounded replacement is intended.
|
||||
- For missing managed output files recorded in state, run `reconcile-state --dry-run`, then apply `reconcile-state` if the missing files should no longer be considered managed.
|
||||
- For configured retention cleanup, run `prune --dry-run`, then apply `prune --apply` after reviewing the managed output list.
|
||||
- For failed writes, inspect the destination bundle path, remove only confirmed partial outputs if needed, then rerun `--dry-run`.
|
||||
- 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-oriented fixes, see [troubleshooting](troubleshooting.md). For config details, see [configuration](config.md). For command syntax, see [CLI](cli.md).
|
||||
For symptom-specific fixes, see [Troubleshooting](troubleshooting.md).
|
||||
|
||||
@@ -94,13 +94,17 @@ The source manifest should remain minimal. Routing, destination selection, publi
|
||||
|
||||
`manifest.json` from the source bundle is not copied to destinations as destination state.
|
||||
|
||||
Each destination bundle path is managed by `.distributor.json`. This file is both the destination sentinel and the destination state record. It records:
|
||||
Each destination bundle path is managed by `.distributor.json`. This file is both the destination sentinel and the destination state record.
|
||||
|
||||
Publish execution writes catalog destination state. One `.distributor.json` records all managed outputs under the destination bundle path, and each output carries its owning pipeline id and destination id.
|
||||
|
||||
Catalog state records:
|
||||
|
||||
- `distributor` state schema version;
|
||||
- pipeline id;
|
||||
- destination id;
|
||||
- publication timestamp;
|
||||
- the normalized source manifest used for publication;
|
||||
- state creation and update timestamps;
|
||||
- catalog state mode;
|
||||
- owner identity for each managed output;
|
||||
- compact source identity for each managed output;
|
||||
- metadata for copied source outputs;
|
||||
- metadata for generated outputs, such as HTML files;
|
||||
- optional URL metadata for published outputs;
|
||||
@@ -110,38 +114,31 @@ A representative destination state file is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 4,
|
||||
"distributor_version": "0.1.0",
|
||||
"pipeline_id": "weather-daily",
|
||||
"destination_id": "static-html",
|
||||
"published_at": "2026-05-30T11:12:00Z",
|
||||
"source": {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:...",
|
||||
"created": "2026-05-30T11:10:00Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "report.md",
|
||||
"sha256": "sha256:...",
|
||||
"size": 12345
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"primary_url": "https://reports.example.com/weather-daily/"
|
||||
"created_at": "2026-05-30T11:12:00Z",
|
||||
"updated_at": "2026-05-30T11:12:00Z",
|
||||
"state": {
|
||||
"mode": "catalog"
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"path": "index.html",
|
||||
"pipeline_id": "weather-daily",
|
||||
"destination_id": "static-html",
|
||||
"source": {
|
||||
"id": "weather.daily.brentwood.2026-05-30",
|
||||
"digest": "sha256:...",
|
||||
"created": "2026-05-30T11:10:00Z"
|
||||
},
|
||||
"kind": "generated",
|
||||
"source_path": "report.md",
|
||||
"transform": "markdown_to_html",
|
||||
"sha256": "sha256:...",
|
||||
"size": 23456,
|
||||
"url": "https://reports.example.com/weather-daily/"
|
||||
"url": "https://reports.example.com/weather-daily/",
|
||||
"created_at": "2026-05-30T11:12:00Z",
|
||||
"updated_at": "2026-05-30T11:12:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -150,11 +147,11 @@ A representative destination state file is:
|
||||
Destination comparison rules are based on `.distributor.json`:
|
||||
|
||||
- No `.distributor.json`: publish normally only if the destination bundle path is empty.
|
||||
- Existing state embeds the same normalized source manifest: skip as already published.
|
||||
- Existing state has the same source id and an older source `created`: replace, subject to destructive-operation safety rules.
|
||||
- Existing state has the same source id and a newer source `created`: skip because the destination is newer than the source.
|
||||
- Existing state has the same source id and same `created` but different digest: fail as a conflict.
|
||||
- Existing state has a different source id: fail as a conflict.
|
||||
- Existing catalog state with additive workflow: write planned outputs and retain unrelated managed outputs.
|
||||
- Existing catalog state with replacement workflow: write planned outputs and remove omitted outputs for the current pipeline and destination owner.
|
||||
- Planned paths that collide with unmanaged storage content fail by default.
|
||||
- Invalid or unsupported destination state fails by default.
|
||||
- Explicit forced replacement may clear the bounded destination bundle path after dry-run review.
|
||||
|
||||
## Publication and Transform Policy
|
||||
|
||||
@@ -181,7 +178,7 @@ Application logic must interact with storage through internal backend interfaces
|
||||
|
||||
Adapters should be thin. Backend adapters should implement storage operations and translate backend-specific errors, but should not make bundle comparison, transform, routing, or replacement decisions.
|
||||
|
||||
Remote file-transfer support should prefer native protocol implementations over shelling out, unless a later design document records a reason to differ.
|
||||
Remote file copy support should prefer native protocol implementations over shelling out, unless a later design document records a reason to differ.
|
||||
|
||||
## Dependency Policy
|
||||
|
||||
@@ -197,11 +194,12 @@ Use this current layout unless the project has a documented reason to differ:
|
||||
|
||||
- `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/upload`: public producer-facing HTTP upload client built on `pkg/bundle`.
|
||||
- `internal/app`: application orchestration and top-level use cases.
|
||||
- `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring.
|
||||
- `internal/config`: configuration structs, defaults, loading, precedence, and validation.
|
||||
- `internal/bundle`: storage-backed source bundle discovery and validation over the public manifest contract.
|
||||
- `internal/state`: `.distributor.json` parsing, validation, comparison, and output metadata.
|
||||
- `internal/state`: `.distributor.json` catalog parsing, validation, and output metadata.
|
||||
- `internal/link`: shared HTTP URL validation for configured and persisted link metadata.
|
||||
- `internal/storage`: backend interfaces, shared path/resource types, backend registry, and storage errors.
|
||||
- `internal/adapters/local`: local filesystem backend.
|
||||
@@ -209,7 +207,7 @@ Use this current layout unless the project has a documented reason to differ:
|
||||
- `internal/adapters/s3`: S3-compatible object storage backend.
|
||||
- `internal/transform`: transform interfaces, registry, planning, and shared transform models.
|
||||
- `internal/transform/markdown`: Markdown-to-HTML implementation.
|
||||
- `internal/publish`: destination planning, reconciliation, safety checks, and publish execution.
|
||||
- `internal/publish`: destination planning, catalog workflow safety checks, and publish execution.
|
||||
- `internal/notify`: notification interface and MVP no-op notifier.
|
||||
- `internal/logging`: logging setup and shared logging helpers.
|
||||
|
||||
@@ -244,7 +242,7 @@ Pipeline configuration should express:
|
||||
- per-destination transform policy;
|
||||
- per-destination public link policy;
|
||||
- validation behavior;
|
||||
- destination conflict/replacement behavior.
|
||||
- per-destination workflow and retention behavior.
|
||||
|
||||
## Modules and Registries
|
||||
|
||||
@@ -274,7 +272,7 @@ Errors should be actionable and preserve context. Wrap errors with operation, pi
|
||||
|
||||
Errors and logs must not expose secrets.
|
||||
|
||||
Use structured logging where practical. Logs should describe discovery, validation, planned actions, skipped transfers, conflicts, replacements, external calls, retries, and failure causes, but should not include large report contents by default.
|
||||
Use structured logging where practical. Logs should describe discovery, validation, planned actions, skipped copies, conflicts, replacements, external calls, retries, and failure causes, but should not include large report contents by default.
|
||||
|
||||
Skip and no-op decisions should be logged at an appropriate level so operators can distinguish successful publication from intentional no-op behavior.
|
||||
|
||||
@@ -323,9 +321,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/`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -7,28 +7,29 @@ Use it with `docs/policy/architecture.md` and `docs/policy/documentation.md`.
|
||||
|
||||
- `cmd/distributor`: executable entrypoint only.
|
||||
- `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/cli`: standard-library command parsing, flags, help text, and command wiring.
|
||||
- `internal/config`: YAML configuration structs, loading, defaults, and validation.
|
||||
- `internal/bundle`: storage-backed source bundle discovery and validation using the public manifest contract.
|
||||
- `internal/state`: destination `.distributor.json` parsing, validation, and comparison.
|
||||
- `internal/state`: destination `.distributor.json` catalog parsing, validation, and output metadata.
|
||||
- `internal/storage`: backend interface, registry, logical path rules, typed errors, and shared storage helpers.
|
||||
- `internal/adapters/local`: local filesystem backend.
|
||||
- `internal/adapters/ssh`: SSH/SFTP backend.
|
||||
- `internal/adapters/s3`: S3-compatible object storage backend.
|
||||
- `internal/storage/fake`: in-memory backend for tests.
|
||||
- `internal/publish`: destination inspection, output planning, reconciliation, execution, managed cleanup, and explicit forced replacement.
|
||||
- `internal/publish`: destination inspection, output planning, catalog workflow planning, execution, managed cleanup, and explicit forced replacement.
|
||||
- `internal/transform`: transform interface and registry.
|
||||
- `internal/transform/markdown`: Markdown-to-HTML transform.
|
||||
- `internal/notify`: notification interface and current no-op notifier.
|
||||
- `internal/testutil`: shared test fixtures. Production code must not import this package.
|
||||
- `docs`: current user, operator, policy, internal, and roadmap documentation.
|
||||
- `docs`: current user, operator, consumer, integration, policy, internal, and roadmap documentation.
|
||||
- `examples`: copyable example configs and source bundles.
|
||||
|
||||
Do not create new top-level package families such as public `pkg/...` packages
|
||||
beyond `pkg/bundle`, generic workflow containers, or service-specific adapter
|
||||
directories unless the architecture policy or a current roadmap explicitly
|
||||
calls for them.
|
||||
beyond `pkg/bundle` and `pkg/upload`, generic workflow containers, or
|
||||
service-specific adapter directories unless the architecture policy or a
|
||||
current roadmap explicitly calls for them.
|
||||
|
||||
## Common Commands
|
||||
|
||||
@@ -45,6 +46,7 @@ go test ./internal/config
|
||||
go test ./internal/cli ./internal/app
|
||||
go test ./internal/publish ./internal/state
|
||||
go test ./internal/transform/markdown
|
||||
go test ./pkg/bundle ./pkg/upload
|
||||
```
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Do not import concrete transform implementations from `internal/publish`; app-level wiring owns transform registration.
|
||||
- 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/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 ./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 ./...`
|
||||
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/cli.md` canonical for command syntax and workflows.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Project documentation must help four audiences:
|
||||
Project documentation must help five audiences:
|
||||
|
||||
1. users who need to run the application;
|
||||
2. administrators/operators who need to configure and operate it;
|
||||
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.
|
||||
|
||||
@@ -42,11 +43,14 @@ Canonical homes:
|
||||
|
||||
- project purpose and quickstart: `README.md`
|
||||
- development principles: `docs/policy/architecture.md`
|
||||
- public HTTP API reference: `docs/api.md`
|
||||
- configuration reference: `docs/config.md`
|
||||
- CLI reference: `docs/cli.md`
|
||||
- operations and recovery: `docs/operations.md`
|
||||
- troubleshooting: `docs/troubleshooting.md`
|
||||
- public API/package consumer guidance: `docs/consumers/`
|
||||
- implemented internals: `docs/internal/`
|
||||
- external protocol, service, and file-format contracts: `docs/integrations/`
|
||||
- future work: `docs/roadmap/`
|
||||
- contributor workflow: `docs/policy/development.md`
|
||||
- copyable examples: `examples/`
|
||||
@@ -119,6 +123,31 @@ Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- validated examples under `examples/`
|
||||
|
||||
### Public HTTP API service
|
||||
|
||||
Required:
|
||||
- `docs/api.md`
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/consumers/`, for task-oriented client integration guides
|
||||
- `docs/integrations/`, for upstream/downstream service contracts
|
||||
- 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
|
||||
|
||||
### README.md
|
||||
@@ -161,6 +190,32 @@ It should include:
|
||||
|
||||
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
||||
|
||||
### docs/api.md
|
||||
|
||||
**Audience:** external HTTP API consumers, developers, LLM coding agents integrating by HTTP
|
||||
|
||||
Required for projects whose primary public interface is HTTP.
|
||||
|
||||
`docs/api.md` is the canonical public HTTP API contract. It should be normative for external consumers and should not be duplicated by README, operations docs, consumer guides, or integration docs.
|
||||
|
||||
It should include:
|
||||
|
||||
1. base URL conventions;
|
||||
2. authentication and authorization behavior, if implemented;
|
||||
3. response envelope;
|
||||
4. supported media types and content negotiation behavior;
|
||||
5. shared query parameters;
|
||||
6. endpoint reference grouped by route family;
|
||||
7. request parameters and validation rules;
|
||||
8. response fields, units, nullability, and optionality;
|
||||
9. error response shape and status codes;
|
||||
10. pagination, caching, rate-limit, idempotency, and retry behavior, if implemented;
|
||||
11. compact request and response examples.
|
||||
|
||||
It must document only implemented endpoints and behavior. Planned endpoints, proposed fields, future filters, and experimental response shapes belong only under `docs/roadmap/`.
|
||||
|
||||
For HTTP API projects, `docs/consumers/` may provide task-oriented client integration guides, but those guides should link to `docs/api.md` for the authoritative endpoint contract.
|
||||
|
||||
### docs/policy/development.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
@@ -244,6 +299,35 @@ Each entry should include:
|
||||
- safe fix;
|
||||
- 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.
|
||||
|
||||
For projects whose public API is HTTP, `docs/consumers/` is not required, and it should not duplicate the endpoint reference in `docs/api.md`. If present, it may provide practical integration workflows, client-specific examples, or migration notes that link back to `docs/api.md`.
|
||||
|
||||
`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/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
@@ -289,7 +373,9 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
For public HTTP API services, `docs/integrations/` should document upstream, downstream, storage, protocol, or runtime contracts that the service depends on or bridges. It should not become a second copy of the public HTTP endpoint reference; that belongs in `docs/api.md`.
|
||||
|
||||
Use one file per integration where useful.
|
||||
|
||||
@@ -346,8 +432,10 @@ Before merging documentation changes, verify:
|
||||
|
||||
- README is concise and orientation-focused.
|
||||
- `docs/policy/architecture.md` describes development principles.
|
||||
- `docs/api.md` is the canonical HTTP contract for HTTP API services.
|
||||
- Future work appears only under `docs/roadmap/`.
|
||||
- User-facing docs avoid unnecessary internals.
|
||||
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
|
||||
- Developer-facing docs preserve boundaries and invariants.
|
||||
- Config examples match the schema.
|
||||
- CLI examples match real commands and flags.
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
# Code Quality and Deduplication Audit
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
Overall code quality is strong. The repository has clear package boundaries, good current-behavior documentation, focused adapter packages, and tests close to most implemented behavior. The most important cleanup opportunities are narrow and behavior-preserving rather than architectural.
|
||||
|
||||
Top three refactoring targets:
|
||||
|
||||
1. HTTP upload admission, body staging, and archive validation are split across `internal/app` and `internal/ingest` in a way that duplicates size and content-type policy and buffers uploads in memory.
|
||||
2. Runtime config loading, default config path selection, secret loading, and warning projection are repeated across app entrypoints.
|
||||
3. Run orchestration mixes destination processing, failure aggregation, warning recording, and report event ordering in one large loop, making future changes harder to review safely.
|
||||
|
||||
The codebase appears ready for a limited cleanup pass. I do not see a major architectural risk that requires a redesign before the next release.
|
||||
|
||||
## 2. Repository map reviewed
|
||||
|
||||
Reviewed policy and current-behavior documentation:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `docs/policy/architecture.md`
|
||||
- `docs/policy/development.md`
|
||||
- `docs/policy/documentation.md`
|
||||
- `docs/config.md`
|
||||
- `docs/cli.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/internal/*.md`
|
||||
- `docs/roadmap/http.md`
|
||||
- `docs/roadmap/implementation.md`
|
||||
|
||||
Reviewed implementation areas:
|
||||
|
||||
- `cmd/distributor`: executable entrypoint.
|
||||
- `internal/cli`: root command, `version`, `run`, `serve`, `validate`, `inspect`, and `manifest create` parsing.
|
||||
- `internal/app`: run orchestration, configured source diagnostics, backend factory, manifest creation, CLI output, HTTP upload server, upload coordinator, and pipeline coordinator.
|
||||
- `internal/config`: config structs, defaults, validation, quantity parsing, S3/SSH helpers, and secrets resolver.
|
||||
- `internal/bundle`: storage-backed source discovery and validation.
|
||||
- `pkg/bundle`: public manifest model, digest logic, manifest building, local validation, and local bundle writer.
|
||||
- `internal/storage` and `internal/storage/fake`: backend interface, path helpers, walk helpers, typed errors, and fake backend.
|
||||
- `internal/adapters/local`, `internal/adapters/ssh`, and `internal/adapters/s3`: runtime storage adapters.
|
||||
- `internal/ingest`: HTTP upload archive staging.
|
||||
- `internal/publish`: destination planning, output selection, link projection, state writing, cleanup, and force replacement.
|
||||
- `internal/state`: destination state parsing, validation, comparison, and JSON projection.
|
||||
- `internal/transform` and `internal/transform/markdown`: transform registry and Markdown rendering.
|
||||
- `internal/link`, `internal/notify`, `internal/logging`, and `internal/testutil`.
|
||||
- `examples`, package tests, and package `testdata`.
|
||||
|
||||
Requested areas that are absent as separate packages:
|
||||
|
||||
- `internal/stage`
|
||||
- `internal/modules`
|
||||
- `internal/validators`
|
||||
- `internal/artifacts`
|
||||
- `internal/manifest`
|
||||
- `internal/schema`
|
||||
- `internal/report`
|
||||
- public `pkg` packages other than `pkg/bundle`
|
||||
|
||||
Those absences are consistent with current architecture policy; the corresponding behavior lives in narrower existing packages.
|
||||
|
||||
## 3. High-confidence deduplication opportunities
|
||||
|
||||
### HTTP upload body handling should be owned by ingestion
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/upload_http.go`
|
||||
- `internal/app/upload_coordinator.go`
|
||||
- `internal/ingest/archive.go`
|
||||
- `internal/app/upload_http_test.go`
|
||||
- `internal/app/upload_http_integration_test.go`
|
||||
- `internal/ingest/archive_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `internal/app/upload_http.go` validates upload content types in `supportedUploadContentType`, while `internal/ingest/archive.go` validates the same content types in `archiveFormat`.
|
||||
- `internal/app/upload_http.go` enforces upload size in `readUploadBody`, while `internal/ingest/archive.go` enforces upload size again in `writeLimited`.
|
||||
- The HTTP handler reads the full upload body into memory before submission, then the coordinator passes a `bytes.Reader` to ingestion.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- The app transport layer now partially owns archive policy that should belong to `internal/ingest`.
|
||||
- Large accepted uploads are buffered in memory even though ingestion already has streaming-to-disk mechanics.
|
||||
- Future archive formats, content types, or upload limit changes would need coordinated edits in multiple packages.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Move supported content-type checking behind an ingestion-owned helper, for example `ingest.ValidateContentType` or `ingest.IsSupportedContentType`.
|
||||
- Change upload admission so the request body is streamed to staging exactly once before the HTTP handler returns `202 Accepted`.
|
||||
- Keep queue-full rejection before reading the body.
|
||||
- Queue a staged local bundle root, not an unread request body. This preserves async distribution while keeping HTTP request lifetime separate from later pipeline execution.
|
||||
- Keep `UploadCoordinator` responsible for queueing, status, per-pipeline serialization, and execution. Keep `internal/ingest` responsible for archive format, size, extraction, cleanup, and source bundle validation.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- HTTP handler rejects full queues without reading the body.
|
||||
- HTTP handler streams a valid body to ingestion and returns `202` only after staging succeeds.
|
||||
- Unsupported content types are rejected through the ingestion-owned content-type policy.
|
||||
- Oversized uploads are rejected without retaining a staged run.
|
||||
- Accepted upload status still transitions through queued/running/succeeded or failed without depending on an open HTTP request body.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Medium. The behavior change is internal but touches admission timing and async execution boundaries. It should be implemented in a focused prompt with existing HTTP integration tests extended first.
|
||||
|
||||
### Runtime config and secret setup should have one app-level helper
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/run.go`
|
||||
- `internal/app/source_select.go`
|
||||
- `internal/app/serve.go`
|
||||
- `internal/app/backends.go`
|
||||
- `internal/app/run_warnings.go`
|
||||
- `internal/config`
|
||||
- `internal/app/*_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Defaulting an empty config path to `config.DefaultConfigPath` appears in `Run`, `RunPipeline`, `RunPipelineWithLocalSource`, and `Serve`.
|
||||
- Config loading and secret loading are separate repeated steps in `buildRunReport`, `selectSourceBundlesFromConfig`, and `Serve`.
|
||||
- Secret conflict warnings are projected in run and configured source diagnostics, while serve loads secrets without using or exposing conflict warning metadata.
|
||||
- Backend factory construction from a config environment is repeated through provider plumbing.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- Config and secret precedence is a public operational policy.
|
||||
- A future change to config discovery, secret conflict reporting, or runtime environment construction could drift between `run`, `serve`, `validate`, and `inspect`.
|
||||
- Tests for secrets and credential resolution need to cover several entrypoints today.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Add a small app-level runtime setup helper, for example `loadRuntimeConfig(optionsConfigPath string) (runtimeConfig, error)`.
|
||||
- The helper should own default config path selection, `config.LoadFile`, `config.LoadSecretEnvironment`, and conversion of secret conflicts into `OutputWarning` values.
|
||||
- Keep config parsing and validation in `internal/config`; the helper should not duplicate config policy.
|
||||
- Let `run`, configured `validate`/`inspect`, and `serve` call the helper and then apply command-specific behavior.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- One focused app test proving default config path selection remains unchanged where injection permits it.
|
||||
- Existing secret conflict JSON/text warning tests for `run`, `validate`, and `inspect`.
|
||||
- Serve startup test proving duplicate and missing upload tokens still fail without leaking values.
|
||||
- S3 explicit credential tests proving the resolver is still used through the helper.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Low to medium. This is a straightforward centralization but touches several command entrypoints.
|
||||
|
||||
### Run destination processing needs a narrow helper boundary
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/run.go`
|
||||
- `internal/app/run_output.go`
|
||||
- `internal/app/run_failures.go`
|
||||
- `internal/app/run_selection.go`
|
||||
- `internal/publish`
|
||||
- `internal/app/run_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Destination backend open failures and publish planning/execution failures each manually add `runFailures`, record summary failure counts, append `RunActionRecord`, and append pipeline event indexes.
|
||||
- `publish.Build` error handling patches missing `Plan` identity fields inline before converting the plan to a run action.
|
||||
- Fixed-path warning emission is interleaved with destination selection and publish plan handling.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- `run --format json` depends on exact action ordering, warnings, partial failures, and summary counters.
|
||||
- Future changes to actions, links, notifications, or HTTP upload reports could accidentally update one failure path but not another.
|
||||
- The current loop is correct but dense enough that small behavior changes are hard to review.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Extract a narrow `runDestination` or `destinationRunner` helper that processes one destination and returns action records, warnings, summary deltas, and failures.
|
||||
- Add a helper for recording a destination-scoped failure that updates `runFailures`, `runSummary`, `RunReport.Actions`, and pipeline events in one place.
|
||||
- Add a helper that normalizes partial `publish.Plan` identity fields before action projection.
|
||||
- Do not introduce a generic workflow engine or stage abstraction.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Preserve existing run text output golden assertions.
|
||||
- Preserve JSON partial-result behavior when planning fails after destination processing begins.
|
||||
- Add one focused test where destination open fails for multiple selected bundles and verify action records, output errors, and summary counters stay aligned.
|
||||
- Add one fixed-path dry-run warning test after extraction to verify event ordering.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Medium. The refactor is behavior-preserving but touches the most important user-facing workflow.
|
||||
|
||||
### Archive path validation duplicates source path policy with a different error surface
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/ingest/archive.go`
|
||||
- `pkg/bundle/path.go`
|
||||
- `internal/storage/path.go`
|
||||
- `internal/ingest/archive_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `cleanArchivePath`, `pkg/bundle.ValidateSourcePath`, and `storage.ValidatePath` all enforce clean slash-separated relative paths with no backslashes, no absolute paths, and no dot segments.
|
||||
- Archive staging needs slightly different policy because directories are allowed and `manifest.json` is allowed only at the root, so the duplication is not completely mechanical.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- Path safety is high-risk behavior.
|
||||
- Future changes to source path rules could miss archive extraction, especially around backslashes, reserved names, or dot segments.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Keep archive-specific rules in `internal/ingest`, but use a shared path-checking primitive where possible.
|
||||
- A good shape is an exported `pkg/bundle.ValidatePathSegmented` only if it fits the public producer API, or an internal helper in ingestion that delegates file-entry validation to `pkg/bundle.ValidateSourcePath` for regular files after handling directory-specific exceptions.
|
||||
- Preserve current archive-specific errors and tests.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Table tests shared or mirrored across bundle path validation and archive path cleaning for absolute paths, traversal, backslashes, dot segments, empty names, root `manifest.json`, nested `manifest.json`, and `.distributor.json`.
|
||||
- Regression tests proving directories are still accepted in archives but symlinks and hardlinks remain rejected.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Low to medium. Path validation changes need careful tests, but the desired change can be small.
|
||||
|
||||
## 4. Medium-confidence opportunities
|
||||
|
||||
### Source and destination backend config shapes could expose a normalized view
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/config/config.go`
|
||||
- `internal/config/defaults.go`
|
||||
- `internal/config/validate.go`
|
||||
- `internal/app/backends.go`
|
||||
- `internal/config/*_test.go`
|
||||
- `internal/app/backends_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `config.Backend` and `config.Destination` duplicate backend fields for local, SSH, S3, and credentials.
|
||||
- Defaults for source backends and destination backends are implemented in separate functions.
|
||||
- App backend opening converts both shapes into `backendOpenSpec`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- New backend fields must be added to both YAML structs, defaulting paths, validation paths, app open-spec conversion, docs, and tests.
|
||||
- The current pattern is easy to understand but likely to drift as more backend-specific fields are added.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Keep the YAML shape unchanged for compatibility.
|
||||
- Add package-local helpers in `internal/config` that return a normalized backend view for either source or destination.
|
||||
- Use that view for shared backend defaulting and validation where it improves clarity.
|
||||
- Keep destination-only fields such as `publish`, `transfer`, `links`, and `path_mapping` on `Destination`.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing source and destination backend validation tests should continue to pass.
|
||||
- Add a table test that validates equivalent local, SSH, and S3 source/destination backend field requirements through the shared view.
|
||||
- Add a test that `http_upload` remains source-only.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Medium. This reduces future drift, but the current duplication is understandable and does not need to be the first cleanup.
|
||||
|
||||
### CLI command scaffolding is mostly shared, but manifest create has special parsing
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/cli/run.go`
|
||||
- `internal/cli/serve.go`
|
||||
- `internal/cli/source_mode.go`
|
||||
- `internal/cli/manifest.go`
|
||||
- `internal/cli/version.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Several commands repeat `flag.NewFlagSet`, `SetOutput`, help handling, format parsing, and usage exit handling.
|
||||
- `manifest create` uses `splitManifestCreateArgs` to allow a positional bundle path before flags, unlike Go's default `flag` behavior.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- CLI syntax and error behavior are public.
|
||||
- A broad CLI helper could accidentally obscure command-specific parsing, but a narrow helper could reduce repeated setup and invalid-format handling.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Do not introduce a CLI framework.
|
||||
- Consider a tiny helper for common `FlagSet` creation and output-format parsing after higher-value app/config cleanup.
|
||||
- Keep `manifest create` custom parsing local unless another command needs the same interspersed positional behavior.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Preserve current CLI usage-error tests.
|
||||
- Add explicit tests for `manifest create <path> --id x`, `manifest create --id x <path>`, and invalid missing flag values before any parser cleanup.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Low if kept narrow; medium if over-generalized.
|
||||
|
||||
### Output DTOs repeat bundle metadata projection
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/validate.go`
|
||||
- `internal/app/inspect.go`
|
||||
- `internal/app/manifest.go`
|
||||
- `internal/app/run_output.go`
|
||||
- `internal/app/output.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `inspect` and `manifest create` both project bundle file metadata into command-specific JSON structs.
|
||||
- `validate`, `inspect`, and `manifest create` each define local result types and file record types.
|
||||
- RFC3339 formatting uses both `time.RFC3339` and the equivalent literal layout string.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- JSON output is now a public interface.
|
||||
- Repeated projection can drift in field names, timestamp formatting, or path display rules.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Add a small app-local projection helper for bundle summaries and manifest file records.
|
||||
- Use `time.RFC3339` instead of literal RFC3339 layouts.
|
||||
- Keep command-specific result structs where the command output semantics differ.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- JSON structural tests for `validate`, `inspect`, and `manifest create` before and after the helper.
|
||||
- A timestamp-format assertion using an offset timestamp to confirm current behavior is preserved.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Low.
|
||||
|
||||
### PipelineRunCoordinator overlaps conceptually with UploadCoordinator
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/run_coordinator.go`
|
||||
- `internal/app/upload_coordinator.go`
|
||||
- `docs/internal/app.md`
|
||||
- `internal/app/run_coordinator_test.go`
|
||||
- `internal/app/upload_coordinator_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Both coordinators define run records, statuses, timestamps, status transitions, context handling, and active pipeline protection.
|
||||
- The upload coordinator additionally queues, stages, expires status records, and serializes same-pipeline upload execution.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- The concepts are similar enough to confuse future contributors.
|
||||
- However, the behavior is not identical: one rejects duplicate active runs, while the other queues accepted uploads.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Do not merge the coordinators now.
|
||||
- Review whether `PipelineRunCoordinator` is still needed as an exported app-level helper. If it is intended for future transports, document that role clearly. If not, remove it and its tests in a separate dead-code cleanup.
|
||||
- If both remain, extract only tiny shared timestamp/status helpers if a real third coordinator appears.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- If retained, keep existing duplicate-run tests.
|
||||
- If removed, run `go test ./internal/app ./internal/cli` and verify no current behavior depended on it.
|
||||
|
||||
Risk level:
|
||||
|
||||
- Low for documentation clarification, medium for removal because it is exported from an internal package and documented for maintainers.
|
||||
|
||||
## 5. Boundary and responsibility concerns
|
||||
|
||||
The major boundaries are sound:
|
||||
|
||||
- CLI parsing stays in `internal/cli`.
|
||||
- Config defaults and validation stay in `internal/config`.
|
||||
- Backend-specific filesystem, SFTP, and S3 behavior stays in adapters.
|
||||
- Manifest semantics are centralized in `pkg/bundle`, with `internal/bundle` adding storage-backed discovery and validation.
|
||||
- Destination state comparison stays in `internal/state`.
|
||||
- Publish planning/execution stays in `internal/publish`.
|
||||
- Transform implementation is behind `internal/transform`.
|
||||
|
||||
Concerns worth addressing:
|
||||
|
||||
- HTTP upload request-body staging currently crosses the app/ingest boundary. The app transport layer should not own body buffering and archive size enforcement beyond admission and HTTP status projection.
|
||||
- Runtime config setup is app-layer behavior, but it is repeated rather than named. A runtime setup helper would clarify the boundary between `internal/config` and command-specific execution.
|
||||
- `internal/app/run.go` owns too many destination-loop details. Extracting a destination processing helper would keep orchestration in app while reducing local complexity.
|
||||
|
||||
Recommended homes:
|
||||
|
||||
- Upload archive policy: `internal/ingest`.
|
||||
- HTTP route/auth/status mapping: `internal/app/upload_http.go`.
|
||||
- Queueing/status/execution: `internal/app/upload_coordinator.go`.
|
||||
- Runtime config plus secret setup: a small helper in `internal/app`, using `internal/config`.
|
||||
- Path and state filenames: keep in `internal/storage`.
|
||||
|
||||
## 6. Path, key, and naming construction review
|
||||
|
||||
Centralized and healthy areas:
|
||||
|
||||
- `storage.StateFileName`, `storage.StatePath`, `storage.ManagedBundleTargets`, `storage.Join`, `storage.DisplayPath`, and logical path validation are used in core publication and tests.
|
||||
- S3 object-key mapping is contained in `internal/adapters/s3`.
|
||||
- SSH and local native path conversion stay inside their adapters.
|
||||
- Link URL construction is isolated in `internal/publish/links.go` and URL validation in `internal/link`.
|
||||
- Manifest name and schema version are centralized in `pkg/bundle`, with `internal/bundle` aliases.
|
||||
|
||||
Areas needing cleanup:
|
||||
|
||||
- Archive path cleaning duplicates much of source/storage path policy and should either delegate to a shared primitive or be tightly covered by mirrored tests.
|
||||
- Upload run ID construction is isolated, but the shape is partly policy. Keep tests around `<pipeline_id>.<utc_timestamp>.<random_suffix>` before changing coordinator code.
|
||||
- Some app tests still construct destination state and source paths locally. `internal/testutil` already covers many cases; additional helper use should be opportunistic, not a sweeping test rewrite.
|
||||
|
||||
## 7. Resolution and catalog review
|
||||
|
||||
Named concept resolution is mostly consistent:
|
||||
|
||||
- Backend names are defined in `internal/config/defaults.go`.
|
||||
- Runtime backend construction is app-owned through `backendFactory` and the storage registry.
|
||||
- Transform names are defined in `internal/transform`, and app wiring owns concrete registration.
|
||||
- Publish/transform policy combinations use `config.ValidatePublishTransformPolicy`.
|
||||
- Configured source selection for `validate` and `inspect` is shared in `source_select.go`.
|
||||
|
||||
Potential refinements:
|
||||
|
||||
- A normalized backend config view would make backend field resolution less repetitive across source and destination config.
|
||||
- Transform and backend registries should remain separate; there is no evidence that a generic registry abstraction would help.
|
||||
- No separate catalog package is needed for the current feature set.
|
||||
|
||||
## 8. Config and command-loading review
|
||||
|
||||
Config loading is reliable and strict:
|
||||
|
||||
- YAML known-field checking is enabled.
|
||||
- Defaults are applied before validation.
|
||||
- Validation collects multiple field errors.
|
||||
- Secrets are loaded without mutating `os.Environ`.
|
||||
- Explicit S3 credential references use the config-owned resolver.
|
||||
|
||||
Likely accidental duplication:
|
||||
|
||||
- Default config path selection and `config.LoadFile` are repeated in several app entrypoints.
|
||||
- Secret loading is repeated in run, source diagnostics, and serve.
|
||||
- Secret conflict warning projection is not represented by one runtime setup result.
|
||||
|
||||
Intentional differences:
|
||||
|
||||
- `serve` loads upload tokens and does not produce CLI JSON output.
|
||||
- `validate` and `inspect` support local-path shortcut mode, while `run` and `serve` are config-driven.
|
||||
- `manifest create` is local filesystem producer tooling and does not load app config.
|
||||
|
||||
Recommended cleanup:
|
||||
|
||||
- Centralize runtime config and secret setup in `internal/app`.
|
||||
- Keep CLI flag parsing local to command files.
|
||||
- Keep `manifest create` outside runtime config loading.
|
||||
|
||||
## 9. State, manifest, or progress handling review
|
||||
|
||||
Manifest handling is in good shape:
|
||||
|
||||
- `pkg/bundle` owns manifest parsing, digest grammar, source path validation, canonical bundle digest, local manifest building, and local bundle writing.
|
||||
- `internal/bundle` delegates normalized manifest semantics to `pkg/bundle` and adds storage-backed validation.
|
||||
- Destination state embeds the normalized manifest and validates through `internal/bundle`/`pkg/bundle`.
|
||||
|
||||
State handling is in good shape:
|
||||
|
||||
- `.distributor.json` parsing, validation, JSON projection, and comparison live in `internal/state`.
|
||||
- Publish execution writes destination state only after outputs are written.
|
||||
- Managed replacement deletes only state-listed outputs plus `.distributor.json`; forced replacement is explicit and bounded.
|
||||
|
||||
Progress/status handling:
|
||||
|
||||
- `RunReport` is the core run result model and supports JSON partial-result output.
|
||||
- HTTP upload status is memory-only and documented as such.
|
||||
- `PipelineRunCoordinator` and `UploadCoordinator` overlap conceptually but have different policies. Avoid merging unless product behavior converges.
|
||||
|
||||
Gaps:
|
||||
|
||||
- HTTP upload staging currently stores the request body in memory before queueing. This is both a quality gap and a mismatch with the intended ingestion boundary.
|
||||
- There is no durable upload status, but this is documented as deferred work and should not be addressed in cleanup.
|
||||
|
||||
## 10. Refactors to avoid
|
||||
|
||||
Avoid these changes in the cleanup pass:
|
||||
|
||||
- Do not introduce a generic workflow engine or stage framework. The current explicit workflow is easier to audit.
|
||||
- Do not add a CLI framework. The standard-library CLI is sufficient and policy-approved.
|
||||
- Do not merge local, SSH, S3, and fake adapters behind a shared implementation layer. Their semantics differ enough that generic helpers would likely hide important behavior.
|
||||
- Do not collapse `pkg/bundle` and `internal/bundle`. The public producer API and storage-backed distributor validation have different responsibilities.
|
||||
- Do not move destination state comparison into `publish` or app orchestration.
|
||||
- Do not redesign JSON output envelopes while doing cleanup.
|
||||
- Do not add durable queues, retry workers, HTTP TLS, zstd, or browser UI under the banner of refactoring. These are feature work.
|
||||
- Do not rewrite tests wholesale to use a new fixture system. Add helpers only where they reduce immediate duplication around changed code.
|
||||
|
||||
## 11. Recommended implementation sequence
|
||||
|
||||
1. HTTP upload staging boundary cleanup.
|
||||
- Move supported content-type policy to `internal/ingest`.
|
||||
- Stop buffering accepted uploads in `upload_http.go`.
|
||||
- Queue staged bundle roots rather than request bodies.
|
||||
- Extend HTTP upload tests first.
|
||||
|
||||
2. Runtime config setup helper.
|
||||
- Add an app-level helper for default config path, config load, secret load, environment resolver, and secret warnings.
|
||||
- Use it from `run`, configured `validate`/`inspect`, and `serve` where applicable.
|
||||
- Preserve command-specific behavior.
|
||||
|
||||
3. Run destination processing extraction.
|
||||
- Add small helpers for destination-scoped failure recording and plan identity normalization.
|
||||
- Extract one-destination processing only if the helper remains readable.
|
||||
- Preserve action ordering and report output.
|
||||
|
||||
4. Backend config normalized view.
|
||||
- Add source/destination backend view helpers in `internal/config`.
|
||||
- Use them for defaulting and validation if tests show the shape remains clear.
|
||||
- Keep YAML structs and public config unchanged.
|
||||
|
||||
5. Bundle output projection cleanup.
|
||||
- Add app-local helpers for file record and bundle summary projection.
|
||||
- Use `time.RFC3339` consistently.
|
||||
- Preserve command-specific JSON field names.
|
||||
|
||||
6. Archive/source path validation test alignment.
|
||||
- Add mirrored path safety tests around ingestion and bundle path validation.
|
||||
- Only centralize code if the helper does not blur archive directory semantics.
|
||||
|
||||
7. Coordinator intent cleanup.
|
||||
- Decide whether `PipelineRunCoordinator` is retained for internal future use.
|
||||
- If retained, clarify comments/docs. If removed, do it as a separate dead-code commit.
|
||||
|
||||
8. Test helper cleanup.
|
||||
- Expand `internal/testutil` only for repeated setup touched by the previous refactors.
|
||||
- Avoid moving every test fixture.
|
||||
|
||||
## 12. Test strategy
|
||||
|
||||
Tests to add before refactoring:
|
||||
|
||||
- HTTP upload handler test proving queue-full rejection does not consume the body.
|
||||
- HTTP upload test proving accepted upload staging completes before `202 Accepted`.
|
||||
- Ingestion content-type policy tests exposed through the new helper.
|
||||
- Run report test covering destination open failure for multiple selected bundles.
|
||||
- CLI JSON tests for `inspect` and `manifest create` timestamp formatting before projection cleanup.
|
||||
|
||||
Tests to run with each cleanup stage:
|
||||
|
||||
- HTTP upload cleanup: `go test ./internal/ingest ./internal/app ./internal/cli`
|
||||
- Config setup cleanup: `go test ./internal/config ./internal/app ./internal/cli`
|
||||
- Run processing cleanup: `go test ./internal/app ./internal/publish ./internal/state`
|
||||
- Backend config view cleanup: `go test ./internal/config ./internal/app`
|
||||
- Output projection cleanup: `go test ./internal/app ./internal/cli`
|
||||
- Path validation cleanup: `go test ./pkg/bundle ./internal/bundle ./internal/ingest ./internal/storage`
|
||||
- Final cleanup validation: `go test ./...`
|
||||
|
||||
Useful read-only checks:
|
||||
|
||||
- `rg -n "LoadFile\\(|LoadSecretEnvironment\\(|DefaultConfigPath" internal/app internal/cli`
|
||||
- `rg -n "application/x-tar|application/gzip|application/x-gzip" internal docs`
|
||||
- `rg -n "2006-01-02T15:04:05Z07:00" internal pkg`
|
||||
- `rg -n "manifest.json|\\.distributor.json|StatePath|DisplayPath" internal pkg`
|
||||
|
||||
## 13. Appendix: findings not worth acting on
|
||||
|
||||
Adapter `ReadFile` and `WriteFile` wrappers:
|
||||
|
||||
- Local, SSH, S3, and fake backends each implement byte helpers in terms of stream helpers. This is small duplication but appropriate because each adapter owns error translation and metadata semantics.
|
||||
|
||||
Adapter traversal implementation:
|
||||
|
||||
- Local filesystem walking, SFTP walking, and S3 pagination look similar at the interface level but are semantically different. Keep traversal mechanics in adapters and shared callback behavior in `storage.WalkEmitter`.
|
||||
|
||||
State and manifest raw JSON parsing:
|
||||
|
||||
- `pkg/bundle` and `internal/state` both parse raw JSON with pointer fields to detect missing required fields. The schemas and error contexts differ, so a generic required-field parser would not be worth the complexity.
|
||||
|
||||
CLI help text:
|
||||
|
||||
- Help text repeats command names and flags. This is acceptable in a small hand-written CLI and keeps command files readable.
|
||||
|
||||
Test fixture strings:
|
||||
|
||||
- Some tests inline YAML snippets or expected output strings despite `internal/testutil`. Inline data is often clearer for edge cases. Only centralize fixture setup when tests are already being changed for a behavior-preserving refactor.
|
||||
|
||||
HTTP JSON response helpers:
|
||||
|
||||
- HTTP API responses use simple JSON objects rather than the CLI JSON envelope. This is intentional because HTTP status codes and route-specific responses are not the same public interface as CLI command output.
|
||||
|
||||
Public and internal bundle validation:
|
||||
|
||||
- `pkg/bundle.ValidateBundle` is local-filesystem producer validation; `internal/bundle.Validate` is storage-backed distributor validation. Keep both, with shared manifest semantics delegated through `pkg/bundle`.
|
||||
@@ -1,400 +0,0 @@
|
||||
# Code Quality Cleanup Roadmap
|
||||
|
||||
## Current Baseline
|
||||
|
||||
The codebase has completed the local, SSH/SFTP, S3, public bundle package,
|
||||
manifest creation, JSON output, path mapping, link generation, and HTTP upload
|
||||
work documented in the current user and internal docs.
|
||||
|
||||
The audit in `docs/roadmap/audit.md` found no major architectural risk. The
|
||||
remaining cleanup work should be narrow, behavior-preserving, and focused on
|
||||
reducing drift in upload staging, runtime config setup, run reporting, backend
|
||||
config handling, output projection, path validation tests, and internal
|
||||
coordination code.
|
||||
|
||||
One intentional behavior change is part of this cleanup roadmap: malformed
|
||||
authenticated upload archives should be rejected before `202 Accepted`, rather
|
||||
than accepted and later marked failed. Valid staged uploads should still run
|
||||
asynchronously after admission.
|
||||
|
||||
## Cleanup Principles
|
||||
|
||||
- Preserve public CLI behavior, config schema, manifest schema, destination
|
||||
state schema, backend behavior, and JSON envelopes unless a stage explicitly
|
||||
says otherwise.
|
||||
- Keep config parsing and validation in `internal/config`.
|
||||
- Keep CLI parsing in `internal/cli`.
|
||||
- Keep upload archive policy in `internal/ingest`; keep HTTP routing,
|
||||
authentication, and status projection in `internal/app`.
|
||||
- Keep backend-specific filesystem, SSH/SFTP, and S3 behavior in adapter
|
||||
packages.
|
||||
- Prefer small package-local helpers over broad abstractions.
|
||||
- Add or strengthen tests before refactoring behavior that affects public
|
||||
output, upload admission, path safety, or run reporting.
|
||||
|
||||
## Active Cleanup Stages
|
||||
|
||||
Implement these stages in order. Each stage should be small enough for one
|
||||
implementation prompt and should leave the repository passing the listed focused
|
||||
tests before moving to the next stage.
|
||||
|
||||
## Stage 1: HTTP Upload Staging Boundary
|
||||
|
||||
Goal:
|
||||
|
||||
Move archive validation and upload body staging fully behind `internal/ingest`,
|
||||
stop app-layer full-body buffering, and reject malformed archives before
|
||||
returning `202 Accepted`.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Add an ingestion-owned content-type helper, such as
|
||||
`ValidateContentType(contentType string) error`, and remove duplicated
|
||||
content-type policy from the HTTP handler.
|
||||
- Replace the current handler-side `readUploadBody` buffering with streaming
|
||||
staging through `internal/ingest`.
|
||||
- Introduce a two-step upload coordinator admission model:
|
||||
- reserve a run id and queue slot before consuming the request body;
|
||||
- stage and validate the archive using that reserved run id;
|
||||
- enqueue only a successfully staged local bundle root for async execution.
|
||||
- Keep queue-full rejection before reading the body.
|
||||
- Preserve `401` for missing or invalid bearer tokens, `415` for unsupported
|
||||
content type, `413` for oversized uploads, and `503` for a full queue.
|
||||
- Return a pre-acceptance `400` for malformed tar/gzip content or invalid
|
||||
staged bundles.
|
||||
- Preserve async queued/running/succeeded/failed status after a valid staged
|
||||
bundle is accepted.
|
||||
- Do not add durable queues, idempotency keys, zstd, or new routes.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- Update `docs/cli.md`, `docs/config.md`, `docs/operations.md`,
|
||||
`docs/troubleshooting.md`, `docs/internal/app.md`, and
|
||||
`docs/internal/ingest.md` only as needed to describe the new
|
||||
pre-acceptance failure boundary.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/ingest ./internal/app ./internal/cli`
|
||||
- Queue-full upload rejection does not read the request body.
|
||||
- Unsupported content type is rejected through ingestion-owned policy.
|
||||
- Oversized uploads return `413` and do not retain a staged run.
|
||||
- Malformed tar/gzip content returns `400` before a run id is issued.
|
||||
- Valid tar and tar.gz uploads return `202` after staging and still transition
|
||||
through async status.
|
||||
- HTTP responses and status records do not leak bearer tokens or secret values.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- `internal/app` no longer buffers the full upload body before staging.
|
||||
- A valid accepted upload has a committed staged bundle root before the `202`
|
||||
response is sent.
|
||||
- Invalid archive content cannot create an accepted run id.
|
||||
|
||||
## Stage 2: Runtime Config And Secret Setup Helper
|
||||
|
||||
Goal:
|
||||
|
||||
Centralize runtime config path resolution, config loading, secret loading,
|
||||
environment resolver creation, and secret-conflict warning projection in one
|
||||
app-layer helper.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Add a small `internal/app` runtime setup helper that:
|
||||
- defaults an empty config path to `config.DefaultConfigPath`;
|
||||
- calls `config.LoadFile`;
|
||||
- calls `config.LoadSecretEnvironment`;
|
||||
- exposes the loaded config, config path, `config.Environment`, and
|
||||
`[]OutputWarning` for secret conflicts.
|
||||
- Use the helper from `Run`, `RunPipeline`, `RunPipelineWithLocalSource`,
|
||||
configured `Validate`/`Inspect`, and `Serve` where applicable.
|
||||
- Keep `manifest create` outside runtime config loading.
|
||||
- Keep YAML structs, defaults, validation, and secret-directory parsing in
|
||||
`internal/config`.
|
||||
- Preserve app test injection points for backend factories and upload handler
|
||||
tests.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- Update `docs/internal/app.md` if helper boundaries or flow descriptions
|
||||
change. User-facing docs should not change unless observable behavior changes.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/config ./internal/app ./internal/cli`
|
||||
- Default config path behavior remains unchanged.
|
||||
- Secret conflict warnings still appear in text and JSON output for `run`,
|
||||
configured `validate`, and configured `inspect`.
|
||||
- `serve` still fails startup safely for missing, empty, or duplicate upload
|
||||
tokens without leaking values.
|
||||
- Explicit S3 credential references still resolve through the config-owned
|
||||
environment resolver.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Runtime commands no longer repeat config path defaulting and secret loading.
|
||||
- Config policy remains owned by `internal/config`.
|
||||
|
||||
## Stage 3: Run Destination Processing Extraction
|
||||
|
||||
Goal:
|
||||
|
||||
Reduce complexity in the main run loop while preserving run report behavior,
|
||||
warning ordering, action ordering, failure aggregation, and text/JSON output.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Extract narrow helpers from `internal/app/run.go` for destination-scoped
|
||||
processing.
|
||||
- Centralize destination-scoped failure recording so one helper updates
|
||||
`runFailures`, `runSummary`, `RunReport.Actions`, and pipeline events.
|
||||
- Centralize normalization of partial `publish.Plan` identity fields before
|
||||
converting plans to run action records.
|
||||
- Keep app orchestration explicit; do not introduce a generic workflow engine,
|
||||
stage framework, or broad runner abstraction.
|
||||
- Preserve independent destination fan-out and partial-result behavior.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- Update `docs/internal/app.md` only if helper names or package layout
|
||||
descriptions materially change.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/app ./internal/publish ./internal/state`
|
||||
- Destination open failures for multiple selected bundles keep action records,
|
||||
output errors, summary counters, and pipeline events aligned.
|
||||
- JSON partial-result output remains unchanged when destination planning or
|
||||
execution fails after a report exists.
|
||||
- Fixed-path dry-run warnings appear in the same order as before.
|
||||
- Existing run text output assertions continue to pass.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- `run.go` delegates destination-scoped record/failure bookkeeping to helpers.
|
||||
- No public output shape or ordering changes.
|
||||
|
||||
## Stage 4: Backend Config Normalized View
|
||||
|
||||
Goal:
|
||||
|
||||
Reduce source/destination backend config drift while preserving the current YAML
|
||||
schema and public config behavior.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Add package-local normalized backend view helpers in `internal/config` for
|
||||
source and destination backend fields.
|
||||
- Use the normalized view to reduce duplication in backend defaulting and
|
||||
validation where it remains clearer than the current paired code.
|
||||
- Keep `config.Backend` and `config.Destination` YAML structs and tags
|
||||
unchanged.
|
||||
- Preserve destination-only policy fields on `Destination`.
|
||||
- Preserve `http_upload` as source-only and invalid for destinations.
|
||||
- Update app backend opening only if the normalized view provides clearer
|
||||
handoff without leaking config internals.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- None expected unless internal docs mention the old paired implementation
|
||||
shape in a way that becomes misleading.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/config ./internal/app`
|
||||
- Equivalent local, SSH, and S3 source/destination validation remains
|
||||
consistent.
|
||||
- Defaults for SSH port/host key policy, S3 region/prefix/force-path-style, and
|
||||
HTTP upload staging fields remain unchanged.
|
||||
- `http_upload` remains valid only for sources.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Adding a future backend field has one obvious defaulting/validation path.
|
||||
- Public config files and examples continue to load unchanged.
|
||||
|
||||
## Stage 5: Command Output Projection Cleanup
|
||||
|
||||
Goal:
|
||||
|
||||
Reduce drift in bundle and file metadata projection for app command JSON
|
||||
results.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Add small app-local projection helpers for bundle summaries and manifest file
|
||||
records used by `validate`, `inspect`, and `manifest create`.
|
||||
- Use `time.RFC3339` consistently instead of equivalent literal layouts.
|
||||
- Preserve existing JSON envelope fields, command names, command-specific result
|
||||
field names, text output, and fatal error behavior.
|
||||
- Do not redesign CLI JSON output or HTTP JSON responses.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- None expected unless tests reveal current docs are stale.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/app ./internal/cli`
|
||||
- JSON output for `validate`, `inspect`, and `manifest create` remains
|
||||
structurally stable.
|
||||
- RFC3339 timestamps remain unchanged, including offset-preserving source
|
||||
timestamps where current behavior preserves them.
|
||||
- Text output remains unchanged.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Bundle/file projection logic is shared where semantics match.
|
||||
- Command-specific result structs remain easy to read.
|
||||
|
||||
## Stage 6: Archive And Source Path Validation Alignment
|
||||
|
||||
Goal:
|
||||
|
||||
Protect path safety by aligning archive path tests with source and storage path
|
||||
policy, without blurring archive-specific rules.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Add mirrored path-safety table tests around `internal/ingest`, `pkg/bundle`,
|
||||
`internal/bundle`, and `internal/storage` where useful.
|
||||
- Keep archive-specific directory handling, root-level manifest rules, duplicate
|
||||
file rejection, symlink rejection, hardlink rejection, and special-entry
|
||||
rejection in `internal/ingest`.
|
||||
- Centralize code only if the helper can preserve clear archive semantics and
|
||||
current error behavior.
|
||||
- Do not add new public `pkg/bundle` APIs unless the existing public API cannot
|
||||
safely support the needed shared behavior.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- None expected unless implementation changes error boundaries or internal
|
||||
package descriptions.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./pkg/bundle ./internal/bundle ./internal/ingest ./internal/storage`
|
||||
- Absolute paths, traversal, backslashes, empty paths, dot segments, nested
|
||||
manifests, `.distributor.json` handling, symlinks, hardlinks, devices, and
|
||||
sockets remain covered.
|
||||
- Archive directories remain accepted where safe.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Path safety policy has regression coverage across archive staging, source
|
||||
bundle validation, and storage logical path validation.
|
||||
- Any code sharing is smaller and clearer than the duplicated logic it replaces.
|
||||
|
||||
## Stage 7: Pipeline Run Coordinator Removal
|
||||
|
||||
Goal:
|
||||
|
||||
Remove the currently unused internal `PipelineRunCoordinator` to avoid
|
||||
maintaining two similar coordination concepts.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Delete `PipelineRunCoordinator`, `PipelineRunRecord`,
|
||||
`DuplicatePipelineRunError`, related helpers, and their tests.
|
||||
- Remove or rewrite `docs/internal/app.md` sections that describe the removed
|
||||
coordinator.
|
||||
- Keep `UploadCoordinator`; do not merge upload queueing with the removed
|
||||
duplicate-run coordinator.
|
||||
- Before deletion, confirm with `rg` that production code does not reference
|
||||
`NewPipelineRunCoordinator`, `PipelineRunCoordinator`, or
|
||||
`DuplicatePipelineRunError`.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- Update `docs/internal/app.md` because it currently documents the coordinator
|
||||
as an internal implemented component.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/app ./internal/cli`
|
||||
- `rg -n "PipelineRunCoordinator|NewPipelineRunCoordinator|DuplicatePipelineRunError" internal docs`
|
||||
should show no stale references after removal.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- No production, test, or internal documentation references remain for the
|
||||
removed coordinator.
|
||||
- Upload coordination behavior is unchanged.
|
||||
|
||||
## Stage 8: Narrow CLI And Test Helper Cleanup
|
||||
|
||||
Goal:
|
||||
|
||||
Apply only low-risk CLI setup and test fixture cleanup that remains useful after
|
||||
the earlier stages.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Add tiny CLI helpers for repeated `flag.FlagSet` setup or output-format
|
||||
parsing only where command behavior remains obvious.
|
||||
- Keep the standard-library CLI; do not introduce a CLI framework.
|
||||
- Keep `manifest create` interspersed positional parsing local unless another
|
||||
command now needs the same parsing behavior.
|
||||
- Expand `internal/testutil` only for repeated setup touched by earlier stages.
|
||||
- Do not rewrite tests wholesale just to use shared helpers.
|
||||
|
||||
Current-behavior documentation updates:
|
||||
|
||||
- None expected unless CLI help or syntax changes. This stage should avoid such
|
||||
changes.
|
||||
|
||||
Tests:
|
||||
|
||||
- `go test ./internal/cli ./internal/app`
|
||||
- CLI usage-error tests remain stable.
|
||||
- `manifest create <path> --id x`, `manifest create --id x <path>`, missing
|
||||
flag values, invalid `--format`, and help output remain covered.
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- Remaining CLI/test cleanup is small, readable, and behavior-preserving.
|
||||
- No public CLI syntax or output changes.
|
||||
|
||||
## Refactors To Avoid
|
||||
|
||||
- Do not introduce a generic workflow engine or stage framework.
|
||||
- Do not add a CLI framework.
|
||||
- Do not merge local, SSH, S3, and fake backend adapter implementations.
|
||||
- Do not collapse `pkg/bundle` and `internal/bundle`.
|
||||
- Do not move destination state comparison into `internal/publish` or
|
||||
`internal/app`.
|
||||
- Do not redesign CLI JSON envelopes.
|
||||
- Do not change HTTP JSON response shapes except where Stage 1 requires
|
||||
pre-acceptance error behavior.
|
||||
- Do not add durable upload queues, retry workers, zstd support, in-app TLS,
|
||||
idempotency keys, browser UI, or other feature work.
|
||||
- Do not rewrite tests wholesale to use new fixture helpers.
|
||||
|
||||
## Validation
|
||||
|
||||
After each implementation stage, run the stage-specific tests listed above.
|
||||
|
||||
After all cleanup stages:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Recommended consistency checks:
|
||||
|
||||
```sh
|
||||
rg -n "LoadFile\\(|LoadSecretEnvironment\\(|DefaultConfigPath" internal/app internal/cli
|
||||
rg -n "application/x-tar|application/gzip|application/x-gzip" internal docs
|
||||
rg -n "2006-01-02T15:04:05Z07:00" internal pkg
|
||||
rg -n "PipelineRunCoordinator|NewPipelineRunCoordinator|DuplicatePipelineRunError" internal docs
|
||||
```
|
||||
|
||||
The cleanup is complete when:
|
||||
|
||||
- all staged tests and `go test ./...` pass;
|
||||
- current-behavior docs describe the implemented Stage 1 upload failure
|
||||
boundary;
|
||||
- `docs/roadmap/audit.md` findings have either been addressed or consciously
|
||||
left in place as noted in this cleanup roadmap;
|
||||
- no completed cleanup behavior is documented only as future work.
|
||||
103
docs/roadmap/future.md
Normal file
103
docs/roadmap/future.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 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 State Repair
|
||||
|
||||
- Unmanaged-file adoption workflow for destination state repair.
|
||||
- Digest-audit mode for managed destination outputs.
|
||||
- Explicit invalid-state repair workflow.
|
||||
- Whole-config state repair command.
|
||||
|
||||
## Destination Retention And Pruning
|
||||
|
||||
- One-off retention overrides for `prune`.
|
||||
- Automatic post-publish pruning.
|
||||
- Path/date parsing retention policies.
|
||||
- Group-level pruning by source publication.
|
||||
- Removal of empty state files.
|
||||
|
||||
## 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.
|
||||
- In-app upload rate limiting.
|
||||
- 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.
|
||||
- Producers do not choose destination ids, destination paths, transforms, links,
|
||||
publish policy, destination workflow, storage backends, or retention policy
|
||||
through upload requests.
|
||||
- Source manifests remain free of routing, destination, transform, credential,
|
||||
workflow, state, and retention data.
|
||||
- Public access policy, TLS termination, and rate limiting belong outside
|
||||
`distributor` unless a future implementation changes that boundary.
|
||||
- `distributor` is not a broad storage synchronization tool unless a future
|
||||
implementation explicitly changes that non-goal.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Roadmap: HTTP Upload Extensions
|
||||
|
||||
## Purpose
|
||||
|
||||
The HTTP upload API is implemented. Current behavior is documented in:
|
||||
|
||||
- [CLI](../cli.md)
|
||||
- [Configuration](../config.md)
|
||||
- [Operations](../operations.md)
|
||||
- [Troubleshooting](../troubleshooting.md)
|
||||
- [Application internals](../internal/app.md)
|
||||
- [Ingestion internals](../internal/ingest.md)
|
||||
|
||||
This roadmap records HTTP upload extensions that are intentionally not part of
|
||||
the current implementation.
|
||||
|
||||
## Deferred Extensions
|
||||
|
||||
### Authentication
|
||||
|
||||
- URL-token authentication for constrained clients.
|
||||
- Additional token lifecycle tooling.
|
||||
- Mutual TLS or other in-app identity mechanisms.
|
||||
|
||||
### Archive Formats
|
||||
|
||||
- Zstandard-compressed tar archives.
|
||||
- Additional content negotiation rules for future archive formats.
|
||||
|
||||
### Status And Queue Durability
|
||||
|
||||
- Durable status persistence across process restarts.
|
||||
- Database-backed queueing.
|
||||
- Recovery semantics for queued or running uploads after a restart.
|
||||
|
||||
### Producer Coordination
|
||||
|
||||
- Producer-supplied idempotency keys.
|
||||
- Run retry endpoints.
|
||||
- Run cancellation endpoints.
|
||||
- Run listing endpoints.
|
||||
|
||||
### Deployment Surface
|
||||
|
||||
- In-app TLS.
|
||||
- Public exposure defaults.
|
||||
- Browser UI.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Current HTTP upload behavior remains intentionally small:
|
||||
|
||||
- `http_upload` is source-only and is not a durable storage backend.
|
||||
- Upload status is memory-only.
|
||||
- Producers submit complete tar or gzip-compressed tar source bundles.
|
||||
- Producers authenticate with `Authorization: Bearer <token>`.
|
||||
- Public access policy, TLS termination, and rate limiting belong outside
|
||||
`distributor` unless a future roadmap explicitly changes that boundary.
|
||||
|
||||
Do not document deferred extensions as available outside `docs/roadmap/`.
|
||||
@@ -1,34 +0,0 @@
|
||||
# HTTP Upload Deferred Work
|
||||
|
||||
## Purpose
|
||||
|
||||
HTTP upload behavior is implemented and documented in the current-behavior
|
||||
manuals:
|
||||
|
||||
- [CLI](../cli.md)
|
||||
- [Configuration](../config.md)
|
||||
- [Operations](../operations.md)
|
||||
- [Troubleshooting](../troubleshooting.md)
|
||||
- [Application internals](../internal/app.md)
|
||||
- [Configuration internals](../internal/config.md)
|
||||
- [Ingestion internals](../internal/ingest.md)
|
||||
|
||||
This file tracks only HTTP upload work that is not implemented.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- URL-token authentication.
|
||||
- Zstandard-compressed archive support.
|
||||
- Durable status persistence across process restarts.
|
||||
- Database-backed queueing.
|
||||
- Producer-supplied idempotency keys.
|
||||
- Run listing, cancellation, and retry endpoints.
|
||||
- In-app TLS.
|
||||
- Public network exposure defaults.
|
||||
- Browser UI.
|
||||
|
||||
## Documentation Rule
|
||||
|
||||
Deferred behavior belongs under `docs/roadmap/` until implemented. Current
|
||||
behavior docs must describe only the active HTTP upload API, configuration,
|
||||
operation, troubleshooting, and internal package contracts.
|
||||
@@ -1,8 +1,14 @@
|
||||
# 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:
|
||||
|
||||
@@ -10,11 +16,17 @@ Diagnostic:
|
||||
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. Pre-workflow destination policy keys for state mode, conflict
|
||||
handling, ownership adoption, or per-comparison copy decisions are rejected.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -22,14 +34,15 @@ Diagnostic:
|
||||
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 unsupported fields using the canonical config reference. Destination behavior is configured with `workflow`, `publish`, `transform`, `path_mapping`, `links`, and `retention`.
|
||||
|
||||
## `validate config ... backend ... is unsupported`
|
||||
Reference: [Configuration](config.md#destination-fields).
|
||||
|
||||
Likely cause: a source or destination uses an unsupported backend name, or a
|
||||
command is trying to execute a backend that is valid only for another workflow.
|
||||
`run`, `validate`, and `inspect` execute `local`, `ssh`, and `s3` sources.
|
||||
`serve` executes `http_upload` sources.
|
||||
## 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:
|
||||
|
||||
@@ -37,131 +50,68 @@ Diagnostic:
|
||||
rg -n "backend:" <config-path>
|
||||
```
|
||||
|
||||
Safe fix: use `backend: local`, `backend: ssh`, or `backend: s3` for normal
|
||||
source and destination workflows. Use `backend: http_upload` only for sources
|
||||
handled by `distributor serve`.
|
||||
Safe fix: use `local`, `ssh`, or `s3` for executable sources and destinations. Use `http_upload` only as a source served by `distributor serve`.
|
||||
|
||||
## `bind HTTP server ... address already in use`
|
||||
Reference: [Configuration](config.md#backend-reference).
|
||||
|
||||
Likely cause: another process is already listening on `server.http.bind`.
|
||||
## CLI Arguments Select The Wrong Source Mode
|
||||
|
||||
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`.
|
||||
|
||||
Likely cause: `validate` or `inspect` mixed local path mode with configured source mode, or omitted the required source selector.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ss -ltnp | rg '<port>'
|
||||
go run ./cmd/distributor validate --help
|
||||
go run ./cmd/distributor inspect --help
|
||||
```
|
||||
|
||||
Safe fix: stop the conflicting process or configure a different
|
||||
`server.http.bind` value. The default bind address is `127.0.0.1:8080`.
|
||||
Safe fix: use either `distributor validate <path>` / `distributor inspect <path>`, or use `--config <path> --pipeline <id>` with optional `--bundle <path>`.
|
||||
|
||||
## `upload token environment variable ... is not set`
|
||||
Reference: [CLI](cli.md#validate).
|
||||
|
||||
Likely cause: a configured `http_upload` source references `token_env`, but the
|
||||
variable is absent from both the real process environment and
|
||||
`secrets.directory`.
|
||||
## Reconcile-State Selector Is Missing Or Wrong
|
||||
|
||||
Symptom: `reconcile-state requires --config`, `requires --pipeline`, `requires --destination`, `pipeline "<id>" not found`, or `destination <id> not found`.
|
||||
|
||||
Likely cause: the command did not identify one configured destination root.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^<token-variable>$'
|
||||
ls -l <secrets-directory>/<token-variable>
|
||||
go run ./cmd/distributor reconcile-state --help
|
||||
rg -n 'pipelines:|destinations:|id:' <config-path>
|
||||
cat <destination-path>/.distributor.json
|
||||
```
|
||||
|
||||
Safe fix: set the real environment variable or create a readable
|
||||
secrets-directory file with the same name. Do not place literal token values in
|
||||
YAML.
|
||||
Safe fix: pass the configured `--config`, `--pipeline`, and `--destination` values that identify the destination root containing the state file.
|
||||
|
||||
## `upload token environment variables ... resolve to the same value`
|
||||
Reference: [CLI](cli.md#reconcile-state).
|
||||
|
||||
Likely cause: two configured `http_upload` pipelines resolve to the same bearer
|
||||
token value.
|
||||
## Prune Selector Or Mode Is Missing Or Wrong
|
||||
|
||||
Symptom: `prune requires --config`, `requires --pipeline`, `requires --destination`, `requires exactly one of --dry-run or --apply`, `pipeline "<id>" not found`, or `destination <id> not found`.
|
||||
|
||||
Likely cause: the command did not identify one configured destination root or did not choose exactly one execution mode.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
rg -n 'token_env:' <config-path>
|
||||
go run ./cmd/distributor prune --help
|
||||
rg -n 'retention:|prune:|pipelines:|destinations:|id:' <config-path>
|
||||
cat <destination-path>/.distributor.json
|
||||
```
|
||||
|
||||
Safe fix: assign a distinct non-empty token value to each `http_upload`
|
||||
pipeline. Distributor does not print the duplicate token value.
|
||||
Safe fix: pass the configured `--config`, `--pipeline`, and `--destination` values that identify the destination root containing the state file. Use `--dry-run` for read-only review or `--apply` for deletion, but not both.
|
||||
|
||||
## `POST /upload` returns `401`
|
||||
Reference: [CLI](cli.md#prune).
|
||||
|
||||
Likely cause: the request is missing `Authorization: Bearer <token>` or the
|
||||
token does not match any configured `http_upload` pipeline.
|
||||
## Output Format Is Invalid
|
||||
|
||||
Diagnostic:
|
||||
Symptom: `format must be text or json`.
|
||||
|
||||
```sh
|
||||
curl -i -X POST http://127.0.0.1:8080/upload \
|
||||
-H "Authorization: Bearer $DISTRIBUTOR_UPLOAD_TOKEN" \
|
||||
-H "Content-Type: application/x-tar" \
|
||||
--data-binary @bundle.tar
|
||||
```
|
||||
|
||||
Safe fix: use the token value resolved by the configured `token_env`. Do not
|
||||
include token values in logs or tickets.
|
||||
|
||||
## `POST /upload` returns `413`
|
||||
|
||||
Likely cause: the request body exceeds the selected pipeline's
|
||||
`source.max_upload_size` or the default `server.http.max_upload_size`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -lh bundle.tar bundle.tar.gz
|
||||
rg -n 'max_upload_size:' <config-path>
|
||||
```
|
||||
|
||||
Safe fix: upload a smaller archive, remove unnecessary files from the source
|
||||
bundle, or raise the configured upload size limit.
|
||||
|
||||
## `POST /upload` returns `415`
|
||||
|
||||
Likely cause: the upload uses an unsupported content type. The server accepts
|
||||
uncompressed tar and gzip-compressed tar archives only.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
file bundle.tar.gz
|
||||
```
|
||||
|
||||
Safe fix: send `Content-Type: application/x-tar`, `application/gzip`, or
|
||||
`application/x-gzip`, matching the archive format.
|
||||
|
||||
## `POST /upload` returns `503`
|
||||
|
||||
Likely cause: the in-memory upload queue is full.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
rg -n 'queue_size|max_concurrency' <config-path>
|
||||
```
|
||||
|
||||
Safe fix: retry after active uploads finish, or increase `server.http.queue_size`
|
||||
for the deployment.
|
||||
|
||||
## `GET /runs/<run_id>` returns `404`
|
||||
|
||||
Likely cause: the run id is wrong, the process restarted, or the completed
|
||||
status record expired after `server.http.retention`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
curl -i http://127.0.0.1:8080/runs/<run-id>
|
||||
rg -n 'retention:' <config-path>
|
||||
```
|
||||
|
||||
Safe fix: use the exact `run_id` returned by `POST /upload`. If status retention
|
||||
is too short for operators, increase `server.http.retention`.
|
||||
|
||||
## `--format: format must be text or json`
|
||||
|
||||
Likely cause: a command was run with an unsupported output format.
|
||||
Likely cause: an unsupported value was passed to `--format`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -169,11 +119,15 @@ Diagnostic:
|
||||
go run ./cmd/distributor run --help
|
||||
```
|
||||
|
||||
Safe fix: use `--format text` or `--format json`. Help and usage output are always text.
|
||||
Safe fix: use `--format text` or `--format json`.
|
||||
|
||||
## `--format json` wrote no JSON output
|
||||
Reference: [CLI](cli.md#common-output-format).
|
||||
|
||||
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.
|
||||
## 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:
|
||||
|
||||
@@ -181,210 +135,15 @@ Diagnostic:
|
||||
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.
|
||||
Safe fix: read stderr, fix the setup problem, then rerun. Partial destination failures during `run` can produce JSON; fatal setup failures do not.
|
||||
|
||||
## `configured source mode requires --pipeline`
|
||||
Reference: [CLI](cli.md#output-and-exit-behavior).
|
||||
|
||||
Likely cause: `validate` or `inspect` was run with `--config` but without an explicit pipeline id.
|
||||
## Source Pipeline Is Not Found
|
||||
|
||||
Diagnostic:
|
||||
Symptom: `pipeline "<id>" not found`.
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --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.
|
||||
|
||||
## `does not accept a local path with --config, --pipeline, or --bundle`
|
||||
|
||||
Likely cause: local-path mode and configured source mode were mixed in one `validate` or `inspect` command.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor inspect --help
|
||||
```
|
||||
|
||||
Safe fix: use either `distributor inspect <local-path>` or `distributor inspect --config <path> --pipeline <id>`, not both.
|
||||
|
||||
## `--format json` exited non-zero with `ok: false`
|
||||
|
||||
Likely cause: `run` began planning or executing destinations, and at least one destination failed 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, `result.actions`, and `result.summary`. Fix the failed destination, then preview with `--dry-run --format json` before retrying.
|
||||
|
||||
## `prefix must be a clean relative slash-separated path`
|
||||
|
||||
Likely cause: S3 `prefix` contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: use a clean relative prefix such as `reports/archive`, or omit `prefix`.
|
||||
|
||||
## `NoSuchBucket`, `InvalidBucketName`, or `not_found`
|
||||
|
||||
Likely cause: the S3 bucket, endpoint, or prefix is wrong, or the configured credentials cannot see the requested object.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: verify `endpoint`, `bucket`, `region`, `force_path_style`, and `prefix`. For S3-compatible services, keep `force_path_style: true` unless the service requires virtual-host addressing.
|
||||
|
||||
## `AccessDenied`, `InvalidAccessKeyId`, or `SignatureDoesNotMatch`
|
||||
|
||||
Likely cause: S3 credentials are missing, wrong, empty, or lack permission for the bucket or prefix.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^(<access-key-variable>|<secret-key-variable>)$'
|
||||
ls -l <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: provide both configured credential environment variables through the real environment or `secrets.directory`, or omit explicit credential fields to use the AWS SDK default credential chain.
|
||||
|
||||
## S3 endpoint connection failures
|
||||
|
||||
Likely cause: the endpoint URL is unreachable, uses the wrong scheme, or does not match the configured path-style mode.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
curl -I <endpoint>
|
||||
```
|
||||
|
||||
Safe fix: correct `endpoint`, network routing, TLS settings outside distributor, or `force_path_style`. Distributor does not provide insecure TLS bypass configuration.
|
||||
|
||||
## `load secrets directory ... no such file or directory`
|
||||
|
||||
Likely cause: `secrets.directory` points to a missing directory.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -ld <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: create or mount the directory before running, or remove `secrets.directory` if no credential files are needed.
|
||||
|
||||
## `load secrets directory ... permission denied`
|
||||
|
||||
Likely cause: the service user cannot read the configured secrets directory.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -ld <secrets-directory>
|
||||
namei -l <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: adjust the directory path or deployment permissions so the service user can read the directory. Distributor does not enforce owner, group, or mode policy beyond OS read access.
|
||||
|
||||
## `secret filename ... is invalid`
|
||||
|
||||
Likely cause: a regular file in `secrets.directory` does not match `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
find <secrets-directory> -maxdepth 1 -type f -printf '%f\n'
|
||||
```
|
||||
|
||||
Safe fix: rename the file to a valid credential environment variable name, or remove it from the secrets directory.
|
||||
|
||||
## `credential environment variable ... is not set`
|
||||
|
||||
Likely cause: a backend credential field references an environment variable that is absent from both the real process environment and the configured secrets directory.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^<variable-name>$'
|
||||
ls -l <secrets-directory>/<variable-name>
|
||||
```
|
||||
|
||||
Safe fix: set the real environment variable or create a readable secrets-directory file with the same name.
|
||||
|
||||
## `secret ... ignored because the real environment already has that variable`
|
||||
|
||||
Likely cause: the real process environment and secrets directory both define the variable with different values.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
env | cut -d= -f1 | rg '^<variable-name>$'
|
||||
ls -l <secrets-directory>/<variable-name>
|
||||
```
|
||||
|
||||
Safe fix: remove one source of the credential or make the deployment intentionally prefer the real environment value. Distributor does not print either value.
|
||||
|
||||
## `host is required for ssh backend`
|
||||
|
||||
Likely cause: SSH config is missing the structured `host` field, or an old URL-style SSH config is still in use.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: configure SSH with `host`, optional `user` and `port`, and `path`. SSH URLs are not part of the active config schema.
|
||||
|
||||
## `no SSH auth methods configured`
|
||||
|
||||
Likely cause: neither an SSH agent nor `ssh_key_file` is available.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
test -n "$SSH_AUTH_SOCK" && ssh-add -l
|
||||
ls -l <ssh-key-file>
|
||||
```
|
||||
|
||||
Safe fix: start an SSH agent with an appropriate key loaded, or configure `ssh_key_file` with a readable private key.
|
||||
|
||||
## `host key ... is unknown` or `known_hosts is required`
|
||||
|
||||
Likely cause: strict host key checking has no known host key, or `accept-new` cannot persist a new key.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -l <known-hosts-path>
|
||||
ssh-keygen -F <host> -f <known-hosts-path>
|
||||
```
|
||||
|
||||
Safe fix: configure a writable `known_hosts` path for `accept-new`, pre-populate `known_hosts` for `strict`, or explicitly use `host_key_policy: off` only for insecure test environments.
|
||||
|
||||
## `host key ... has changed`
|
||||
|
||||
Likely cause: the remote server presented a different host key than the one recorded in `known_hosts`.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ssh-keygen -F <host> -f <known-hosts-path>
|
||||
```
|
||||
|
||||
Safe fix: verify the server identity out of band before updating `known_hosts`. Do not switch to `host_key_policy: off` to bypass an unexpected changed key.
|
||||
|
||||
## `pipeline "<id>" not found`
|
||||
|
||||
Likely cause: configured source validation or inspection requested a pipeline id that is not present in the config file.
|
||||
Likely cause: configured source diagnostics or upload processing selected a pipeline id that is absent from the loaded config.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -392,11 +151,15 @@ Diagnostic:
|
||||
rg -n "id:" <config-path>
|
||||
```
|
||||
|
||||
Safe fix: pass an existing pipeline id with `--pipeline`, or update the config.
|
||||
Safe fix: pass an existing `--pipeline` value or correct the pipeline id in config.
|
||||
|
||||
## `stat ssh ... not_found`, `stat s3 ... not_found`, or `no bundles found`
|
||||
Reference: [Configuration](config.md#pipelines).
|
||||
|
||||
Likely cause: the configured source root is wrong, unreadable, or does not contain source bundles.
|
||||
## 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:
|
||||
|
||||
@@ -404,36 +167,15 @@ Diagnostic:
|
||||
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`.
|
||||
Safe fix: correct the configured source backend root, permissions, prefix, or `--bundle` path. The selected bundle directory must contain `manifest.json`.
|
||||
|
||||
## `validate command requires a path` or `inspect command requires a path`
|
||||
Reference: [Operations](operations.md#filesystem-and-storage-layout).
|
||||
|
||||
Likely cause: `validate` or `inspect` was run without a local path and without configured source mode.
|
||||
## Source Manifest Or Files Fail Validation
|
||||
|
||||
Diagnostic:
|
||||
Symptom: `sha256 mismatch`, `size mismatch`, `digest mismatch`, missing manifest fields, or unsafe source paths.
|
||||
|
||||
```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.
|
||||
Likely cause: files changed after `manifest.json` was written, the manifest digest is stale, or the producer wrote invalid bundle paths.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -441,11 +183,15 @@ Diagnostic:
|
||||
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.
|
||||
Safe fix: regenerate the producer bundle and manifest together. Do not edit destination state to work around source validation failures.
|
||||
|
||||
## `destination has content but no distributor state`
|
||||
Reference: [Operations](operations.md#cleanup-and-recovery).
|
||||
|
||||
Likely cause: the destination path is not empty and has no `.distributor.json` state file, so `distributor` will not claim it as managed.
|
||||
## Destination Has Unmanaged Content
|
||||
|
||||
Symptom: `destination has content but no distributor state`, `destination output path ... exists but is not managed by catalog state`, or a plan reason containing `fail_unmanaged`.
|
||||
|
||||
Likely cause: the destination bundle path contains files but no valid `.distributor.json`, or a planned output path collides with storage content that valid catalog state does not record. `distributor` will not claim unmanaged content by default.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -453,24 +199,91 @@ Diagnostic:
|
||||
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.
|
||||
Safe fix: choose an empty destination path, move unrelated files aside, or preview `run --dry-run --force` only after confirming the reported destination bundle path is safe to replace.
|
||||
|
||||
## `fail_conflict`
|
||||
Reference: [Operations](operations.md#forced-replacement-workflow).
|
||||
|
||||
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.
|
||||
## Destination State Is Invalid Or Unsupported
|
||||
|
||||
Symptom: `fail_conflict`, `parse distributor state`, `state schema_version must be 4`, or `unsupported future destination state`.
|
||||
|
||||
Likely cause: `.distributor.json` is invalid JSON, has invalid catalog fields, or uses an unsupported future schema.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
cat <destination-path>/.distributor.json
|
||||
go run ./cmd/distributor inspect <source-root>
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run --format json
|
||||
```
|
||||
|
||||
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`.
|
||||
Safe fix: restore a valid catalog state file from backup, choose a different destination path, or use `--force` only after `run --dry-run --force` reports the intended bounded `force_replace`.
|
||||
|
||||
## `destination is newer and replacement requires --force`
|
||||
Reference: [Operations](operations.md#forced-replacement-workflow).
|
||||
|
||||
Likely cause: config explicitly allows newer-destination replacement, but the current run did not include `--force`.
|
||||
## Destination Uses Superseded Legacy State
|
||||
|
||||
Symptom: dry-run reports a normal catalog action against an older `.distributor.json`, or `reconcile-state` / `prune` reports that the destination state schema is superseded.
|
||||
|
||||
Likely cause: the destination contains a state file written by an older implementation. Publish planning can replace it with catalog state on successful `run`, but maintenance commands only operate on current catalog state.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
cat <destination-path>/.distributor.json
|
||||
go run ./cmd/distributor run --config <config-path> --dry-run
|
||||
```
|
||||
|
||||
Safe fix: preview the publish plan, then run publication if the destination path is correct. The successful run writes schema version `4` catalog state.
|
||||
|
||||
Reference: [Destination State Contract](integrations/destination-state.md).
|
||||
|
||||
## Destination State References Missing Managed Outputs
|
||||
|
||||
Symptom: `reconcile-state --dry-run` reports `status=would_change` or JSON `missing_managed_outputs` entries.
|
||||
|
||||
Likely cause: files that were recorded as managed in `.distributor.json` were removed outside `distributor`, or a previous external cleanup removed destination files without updating state.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor reconcile-state \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--destination <destination-id> \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Safe fix: if the missing files should no longer be managed, rerun the same command without `--dry-run` to remove only the missing managed output records from `.distributor.json`. The command does not delete destination files or adopt unmanaged entries. If the files should exist, restore them from backup or republish instead.
|
||||
|
||||
Reference: [Operations](operations.md#destination-state-repair).
|
||||
|
||||
## Prune Reports No Planned Deletes
|
||||
|
||||
Symptom: `prune --dry-run` reports `planned=0` or JSON `planned_outputs: []`.
|
||||
|
||||
Likely cause: pruning is disabled for the selected destination, every managed output is preserved by `keep_latest`, no managed output is older than `older_than`, or the selected pipeline/destination owner has no eligible outputs.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
rg -n 'retention:|prune:|older_than:|keep_latest:' <config-path>
|
||||
go run ./cmd/distributor prune \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--destination <destination-id> \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
Safe fix: verify the selected destination's retention policy and the managed output `updated_at` timestamps in `.distributor.json`. Adjust config if the policy is too conservative, then rerun `--dry-run` before using `--apply`.
|
||||
|
||||
Reference: [Operations](operations.md#managed-output-pruning).
|
||||
|
||||
## Forced Replacement Appears In A Plan
|
||||
|
||||
Symptom: dry-run output includes `force_replace`.
|
||||
|
||||
Likely cause: the run used `--force`, and catalog planning selected a supported destructive replacement for unmanaged destination content, a planned unmanaged path collision, invalid destination state, or unsupported future destination state.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -478,23 +291,15 @@ Diagnostic:
|
||||
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`.
|
||||
Safe fix: inspect the pipeline id, destination id, backend, and bundle path. Confirm the `destination_path` in JSON output, or the fixed-path `target=.` text output, before applying. Proceed only if deleting everything inside that destination bundle path is intended; `force_replace` then writes planned outputs and schema version `4` catalog state.
|
||||
|
||||
## `force_replace`
|
||||
Reference: [Operations](operations.md#forced-replacement-workflow).
|
||||
|
||||
Likely cause: the current run used `--force` and publish planning selected a supported destructive replacement.
|
||||
## Output Path Collision
|
||||
|
||||
Diagnostic:
|
||||
Symptom: `destination output path collision` or `destination output path ... exists but is not managed by catalog state`.
|
||||
|
||||
```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`.
|
||||
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`, or a planned output path already exists in storage but is not catalog-managed.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -502,11 +307,15 @@ Diagnostic:
|
||||
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.
|
||||
Safe fix: adjust source files or publish/transform policy so copied and generated outputs do not collide. For unmanaged storage collisions, move the unmanaged file aside, choose another destination path, or use forced replacement only when deleting the destination bundle path is intended.
|
||||
|
||||
## A run failed after writing some files
|
||||
Reference: [Configuration](config.md#publish-and-transform-policy).
|
||||
|
||||
Likely cause: a write failed partway through publication. Local, SSH, and S3 execution attempt to clean up outputs written during the failed attempt.
|
||||
## 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:
|
||||
|
||||
@@ -514,4 +323,38 @@ Diagnostic:
|
||||
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).
|
||||
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#catalog-publish-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 contains a filename that cannot be used as a credential variable name.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
ls -la <secrets-directory>
|
||||
```
|
||||
|
||||
Safe fix: create the directory, fix permissions, or rename secret files to valid environment-variable-style names.
|
||||
|
||||
Reference: [Configuration](config.md#secrets).
|
||||
|
||||
29
examples/additive-workflow.yml
Normal file
29
examples/additive-workflow.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
pipelines:
|
||||
- id: example-additive-source
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: catalog-source
|
||||
backend: local
|
||||
path: workspace/published/additive-workflow
|
||||
workflow: additive
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
- id: example-additive-html
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: catalog-html
|
||||
backend: local
|
||||
path: workspace/published/additive-workflow
|
||||
workflow: additive
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: sidecar
|
||||
@@ -7,6 +7,7 @@ pipelines:
|
||||
- id: local-source-archive
|
||||
backend: local
|
||||
path: workspace/published/archive-and-latest/archive
|
||||
workflow: additive
|
||||
path_mapping:
|
||||
mode: preserve_relative
|
||||
publish:
|
||||
@@ -15,6 +16,7 @@ pipelines:
|
||||
- id: local-html-latest
|
||||
backend: local
|
||||
path: workspace/published/archive-and-latest/latest
|
||||
workflow: replacement
|
||||
path_mapping:
|
||||
mode: fixed
|
||||
links:
|
||||
|
||||
@@ -9,11 +9,15 @@ server:
|
||||
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
|
||||
token_env: DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN
|
||||
destinations:
|
||||
- id: local-archive
|
||||
backend: local
|
||||
@@ -21,4 +25,3 @@ pipelines:
|
||||
publish:
|
||||
source: true
|
||||
html: false
|
||||
|
||||
|
||||
19
examples/replacement-workflow.yml
Normal file
19
examples/replacement-workflow.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
pipelines:
|
||||
- id: example-replacement-workflow
|
||||
source:
|
||||
backend: local
|
||||
path: examples/source-bundle
|
||||
destinations:
|
||||
- id: local-latest-html
|
||||
backend: local
|
||||
path: workspace/published/replacement-workflow
|
||||
workflow: replacement
|
||||
path_mapping:
|
||||
mode: fixed
|
||||
publish:
|
||||
source: false
|
||||
html: true
|
||||
transform:
|
||||
markdown_to_html:
|
||||
enabled: true
|
||||
mode: index
|
||||
57
examples/upload-client/main.go
Normal file
57
examples/upload-client/main.go
Normal 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)
|
||||
}
|
||||
@@ -199,10 +199,22 @@ func (b *Backend) HasAny(ctx context.Context, prefix string) (bool, error) {
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
return b.deleteManagedTargets(ctx, storage.OpDeleteManagedBundle, func() ([]string, error) {
|
||||
return storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedOutputs(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
return b.deleteManagedTargets(ctx, storage.OpDeleteManagedOutputs, func() ([]string, error) {
|
||||
return storage.ManagedOutputTargets(bundlePath, managedOutputPaths)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (b *Backend) deleteManagedTargets(ctx context.Context, op string, targetsFunc func() ([]string, error), opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
targets, err := targetsFunc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -213,20 +225,20 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma
|
||||
return err
|
||||
}
|
||||
if nativePath == b.root {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, backendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
return storage.NewError(op, backendName, logicalPath, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
info, err := os.Lstat(nativePath)
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err)
|
||||
return b.translateError(op, logicalPath, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, backendName, logicalPath, storage.ErrUnsupported, nil)
|
||||
return storage.NewError(op, backendName, logicalPath, storage.ErrUnsupported, nil)
|
||||
}
|
||||
if err := os.Remove(nativePath); err != nil {
|
||||
return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err)
|
||||
return b.translateError(op, logicalPath, err)
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(filepath.Dir(nativePath))
|
||||
|
||||
@@ -205,15 +205,27 @@ func (b *Backend) HasAny(ctx context.Context, logicalPrefix string) (bool, error
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
return b.deleteManagedTargets(ctx, storage.OpDeleteManagedBundle, func() ([]string, error) {
|
||||
return storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedOutputs(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
return b.deleteManagedTargets(ctx, storage.OpDeleteManagedOutputs, func() ([]string, error) {
|
||||
return storage.ManagedOutputTargets(bundlePath, managedOutputPaths)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (b *Backend) deleteManagedTargets(ctx context.Context, op string, targetsFunc func() ([]string, error), opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
targets, err := targetsFunc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := b.deleteObject(ctx, storage.OpDeleteManagedBundle, target, opts); err != nil {
|
||||
if err := b.deleteObject(ctx, op, target, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader
|
||||
return storage.Entry{}, storage.NewError(storage.OpWriteFrom, BackendName, logicalPath, storage.ErrConflict, fmt.Errorf("stream size %d does not match expected size %d", written, opts.Size))
|
||||
}
|
||||
if opts.PreferAtomic {
|
||||
if err := b.client.Rename(writePath, nativePath); err != nil {
|
||||
if err := renamePromotedFile(b.client, writePath, nativePath, opts.Overwrite); err != nil {
|
||||
return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
|
||||
}
|
||||
cleanup = false
|
||||
@@ -178,6 +178,27 @@ func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader
|
||||
return b.Stat(ctx, logicalPath)
|
||||
}
|
||||
|
||||
type sftpRenamer interface {
|
||||
PosixRename(oldname, newname string) error
|
||||
Rename(oldname, newname string) error
|
||||
Remove(path string) error
|
||||
}
|
||||
|
||||
func renamePromotedFile(client sftpRenamer, oldname, newname string, overwrite bool) error {
|
||||
if !overwrite {
|
||||
return client.Rename(oldname, newname)
|
||||
}
|
||||
if err := client.PosixRename(oldname, newname); err == nil {
|
||||
return nil
|
||||
} else if !isReplaceRenameFallbackError(err) {
|
||||
return err
|
||||
}
|
||||
if err := client.Remove(newname); err != nil && !isNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return client.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
func (b *Backend) Stat(ctx context.Context, logicalPath string) (storage.Entry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return storage.Entry{}, err
|
||||
@@ -223,10 +244,22 @@ func (b *Backend) HasAny(ctx context.Context, prefix string) (bool, error) {
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
return b.deleteManagedTargets(ctx, storage.OpDeleteManagedBundle, func() ([]string, error) {
|
||||
return storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (b *Backend) DeleteManagedOutputs(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
return b.deleteManagedTargets(ctx, storage.OpDeleteManagedOutputs, func() ([]string, error) {
|
||||
return storage.ManagedOutputTargets(bundlePath, managedOutputPaths)
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (b *Backend) deleteManagedTargets(ctx context.Context, op string, targetsFunc func() ([]string, error), opts storage.DeleteOptions) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
|
||||
targets, err := targetsFunc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -236,20 +269,20 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma
|
||||
return err
|
||||
}
|
||||
if nativePath == b.root {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, BackendName, target, storage.ErrInvalidPath, nil)
|
||||
return storage.NewError(op, BackendName, target, storage.ErrInvalidPath, nil)
|
||||
}
|
||||
info, err := b.client.Lstat(nativePath)
|
||||
if err != nil {
|
||||
if opts.IgnoreMissing && isNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return b.translateError(storage.OpDeleteManagedBundle, target, err)
|
||||
return b.translateError(op, target, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return storage.NewError(storage.OpDeleteManagedBundle, BackendName, target, storage.ErrUnsupported, nil)
|
||||
return storage.NewError(op, BackendName, target, storage.ErrUnsupported, nil)
|
||||
}
|
||||
if err := b.client.Remove(nativePath); err != nil {
|
||||
return b.translateError(storage.OpDeleteManagedBundle, target, err)
|
||||
return b.translateError(op, target, err)
|
||||
}
|
||||
if opts.PruneEmptyDirs {
|
||||
b.pruneEmptyParents(parentOf(target))
|
||||
@@ -452,6 +485,14 @@ func isNotExist(err error) bool {
|
||||
return errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) || errors.Is(err, sftp.ErrSSHFxNoSuchFile)
|
||||
}
|
||||
|
||||
func isReplaceRenameFallbackError(err error) bool {
|
||||
if errors.Is(err, sftp.ErrSSHFxFailure) || errors.Is(err, sftp.ErrSSHFxOpUnsupported) {
|
||||
return true
|
||||
}
|
||||
var statusErr *sftp.StatusError
|
||||
return errors.As(err, &statusErr) && (statusErr.FxCode() == sftp.ErrSSHFxFailure || statusErr.FxCode() == sftp.ErrSSHFxOpUnsupported)
|
||||
}
|
||||
|
||||
func (b *Backend) translateError(op, logicalPath string, err error) error {
|
||||
kind := storage.ErrUnknown
|
||||
switch {
|
||||
|
||||
125
internal/adapters/ssh/backend_test.go
Normal file
125
internal/adapters/ssh/backend_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
func TestRenamePromotedFileUsesPlainRenameWithoutOverwrite(t *testing.T) {
|
||||
client := &recordingRenamer{}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", false); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
if got, want := client.calls, []string{"rename temp index.html"}; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileUsesPosixRenameForOverwrite(t *testing.T) {
|
||||
client := &recordingRenamer{}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
if got, want := client.calls, []string{"posix temp index.html"}; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileFallsBackWhenReplaceRenameUnsupported(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
sftp.ErrSSHFxOpUnsupported,
|
||||
sftp.ErrSSHFxFailure,
|
||||
&sftp.StatusError{Code: uint32(sftp.ErrSSHFxOpUnsupported)},
|
||||
&sftp.StatusError{Code: uint32(sftp.ErrSSHFxFailure)},
|
||||
} {
|
||||
t.Run(err.Error(), func(t *testing.T) {
|
||||
client := &recordingRenamer{posixErr: err}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
want := []string{"posix temp index.html", "remove index.html", "rename temp index.html"}
|
||||
if got := client.calls; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileIgnoresMissingTargetDuringFallback(t *testing.T) {
|
||||
client := &recordingRenamer{
|
||||
posixErr: sftp.ErrSSHFxOpUnsupported,
|
||||
removeErr: &os.PathError{
|
||||
Op: "remove",
|
||||
Path: "index.html",
|
||||
Err: os.ErrNotExist,
|
||||
},
|
||||
}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); err != nil {
|
||||
t.Fatalf("renamePromotedFile() error = %v", err)
|
||||
}
|
||||
want := []string{"posix temp index.html", "remove index.html", "rename temp index.html"}
|
||||
if got := client.calls; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileDoesNotFallbackForPermissionError(t *testing.T) {
|
||||
client := &recordingRenamer{posixErr: sftp.ErrSSHFxPermissionDenied}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); !errors.Is(err, sftp.ErrSSHFxPermissionDenied) {
|
||||
t.Fatalf("renamePromotedFile() error = %v, want permission denied", err)
|
||||
}
|
||||
if got, want := client.calls, []string{"posix temp index.html"}; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePromotedFileReturnsRemoveFallbackError(t *testing.T) {
|
||||
client := &recordingRenamer{
|
||||
posixErr: sftp.ErrSSHFxOpUnsupported,
|
||||
removeErr: sftp.ErrSSHFxPermissionDenied,
|
||||
}
|
||||
if err := renamePromotedFile(client, "temp", "index.html", true); !errors.Is(err, sftp.ErrSSHFxPermissionDenied) {
|
||||
t.Fatalf("renamePromotedFile() error = %v, want permission denied", err)
|
||||
}
|
||||
want := []string{"posix temp index.html", "remove index.html"}
|
||||
if got := client.calls; !equalStrings(got, want) {
|
||||
t.Fatalf("calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingRenamer struct {
|
||||
calls []string
|
||||
posixErr error
|
||||
renameErr error
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func (r *recordingRenamer) PosixRename(oldname, newname string) error {
|
||||
r.calls = append(r.calls, "posix "+oldname+" "+newname)
|
||||
return r.posixErr
|
||||
}
|
||||
|
||||
func (r *recordingRenamer) Rename(oldname, newname string) error {
|
||||
r.calls = append(r.calls, "rename "+oldname+" "+newname)
|
||||
return r.renameErr
|
||||
}
|
||||
|
||||
func (r *recordingRenamer) Remove(path string) error {
|
||||
r.calls = append(r.calls, "remove "+path)
|
||||
return r.removeErr
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for index := range a {
|
||||
if a[index] != b[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -65,55 +65,19 @@ func writeInspectResult(options InspectOptions, selection sourceSelection) error
|
||||
}
|
||||
|
||||
type inspectResult struct {
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
SourceBackend string `json:"source_backend,omitempty"`
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []inspectBundleResult `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"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
SourceBackend string `json:"source_backend,omitempty"`
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []bundleDetailResult `json:"bundles"`
|
||||
}
|
||||
|
||||
func inspectResultFromSelection(selection sourceSelection) inspectResult {
|
||||
result := inspectResult{
|
||||
return inspectResult{
|
||||
PipelineID: selection.PipelineID,
|
||||
SourceBackend: selection.SourceBackend,
|
||||
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 {
|
||||
@@ -134,7 +98,7 @@ func writeInspection(w io.Writer, selection sourceSelection) error {
|
||||
"- path=%s id=%s created=%s digest=%s files=%d\n",
|
||||
storage.DisplayPath(sourceBundle.RootRelativePath),
|
||||
sourceBundle.Manifest.ID,
|
||||
sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"),
|
||||
formatManifestCreated(sourceBundle.Manifest.Created),
|
||||
sourceBundle.Manifest.Digest,
|
||||
len(sourceBundle.Manifest.Files),
|
||||
); err != nil {
|
||||
|
||||
@@ -3,9 +3,12 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
err := Inspect(context.Background(), InspectOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a path") {
|
||||
|
||||
@@ -92,37 +92,23 @@ func normalizeManifestFiles(files []string) []string {
|
||||
}
|
||||
|
||||
type manifestCreateResult struct {
|
||||
ManifestPath string `json:"manifest_path"`
|
||||
Root string `json:"root"`
|
||||
ID string `json:"id"`
|
||||
Created string `json:"created"`
|
||||
Digest string `json:"digest"`
|
||||
FileCount int `json:"file_count"`
|
||||
Files []manifestCreateFileResult `json:"files"`
|
||||
}
|
||||
|
||||
type manifestCreateFileResult struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
ManifestPath string `json:"manifest_path"`
|
||||
Root string `json:"root"`
|
||||
ID string `json:"id"`
|
||||
Created string `json:"created"`
|
||||
Digest string `json:"digest"`
|
||||
FileCount int `json:"file_count"`
|
||||
Files []manifestFileResult `json:"files"`
|
||||
}
|
||||
|
||||
func manifestCreateResultFromManifest(root string, manifest producerbundle.Manifest) manifestCreateResult {
|
||||
result := manifestCreateResult{
|
||||
return manifestCreateResult{
|
||||
ManifestPath: filepath.ToSlash(filepath.Join(root, producerbundle.ManifestName)),
|
||||
Root: filepath.ToSlash(root),
|
||||
ID: manifest.ID,
|
||||
Created: manifest.Created.Format(time.RFC3339),
|
||||
Created: formatManifestCreated(manifest.Created),
|
||||
Digest: manifest.Digest,
|
||||
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
|
||||
}
|
||||
|
||||
42
internal/app/manifest_test.go
Normal file
42
internal/app/manifest_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
83
internal/app/output_projection.go
Normal file
83
internal/app/output_projection.go
Normal 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)
|
||||
}
|
||||
328
internal/app/prune.go
Normal file
328
internal/app/prune.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type PruneOptions struct {
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
DryRun bool
|
||||
Now time.Time
|
||||
Stdout io.Writer
|
||||
OutputFormat OutputFormat
|
||||
}
|
||||
|
||||
type PrunePlanOptions struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type PrunePlanReport struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
DestinationID string `json:"destination_id"`
|
||||
OwnerScope PruneOwnerScope `json:"owner_scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CheckedCount int `json:"checked_count"`
|
||||
PrunedOutputs []PruneOutputRecord `json:"pruned_outputs"`
|
||||
PreservedOutputs []PruneOutputRecord `json:"preserved_outputs"`
|
||||
}
|
||||
|
||||
type PruneReport struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
DestinationID string `json:"destination_id"`
|
||||
Backend string `json:"backend"`
|
||||
RootPath string `json:"root_path"`
|
||||
OwnerScope PruneOwnerScope `json:"owner_scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CheckedCount int `json:"checked_count"`
|
||||
PlannedOutputs []PruneOutputRecord `json:"planned_outputs"`
|
||||
DeletedOutputs []PruneOutputRecord `json:"deleted_outputs"`
|
||||
PreservedOutputs []PruneOutputRecord `json:"preserved_outputs"`
|
||||
FailedOutput *PruneOutputRecord `json:"failed_output,omitempty"`
|
||||
StateChanged bool `json:"state_changed"`
|
||||
WouldChange bool `json:"would_change"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type PruneOwnerScope struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
DestinationID string `json:"destination_id"`
|
||||
}
|
||||
|
||||
type PruneOutputRecord struct {
|
||||
Path string `json:"path"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Owner *PruneOwnerScope `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
func Prune(ctx context.Context, options PruneOptions) (PruneReport, error) {
|
||||
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
return pruneSetup(ctx, setup, options)
|
||||
}
|
||||
|
||||
func pruneConfigWithBackendFactory(ctx context.Context, cfg config.Config, options PruneOptions, provider backendFactoryProvider) (PruneReport, error) {
|
||||
setup, err := runtimeSetupFromConfig("", cfg)
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
return pruneSetupWithBackendFactory(ctx, setup, options, provider)
|
||||
}
|
||||
|
||||
func pruneSetup(ctx context.Context, setup runtimeSetup, options PruneOptions) (PruneReport, error) {
|
||||
return pruneSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
|
||||
}
|
||||
|
||||
func pruneSetupWithBackendFactory(ctx context.Context, setup runtimeSetup, options PruneOptions, provider backendFactoryProvider) (PruneReport, error) {
|
||||
if err := requirePruneScope(options); err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
pipeline, ok := findPipeline(setup.Config, options.PipelineID)
|
||||
if !ok {
|
||||
return PruneReport{}, PipelineNotFoundError{ID: options.PipelineID}
|
||||
}
|
||||
destination, ok := findDestination(pipeline, options.DestinationID)
|
||||
if !ok {
|
||||
return PruneReport{}, fmt.Errorf("pipeline %s destination %s not found", options.PipelineID, options.DestinationID)
|
||||
}
|
||||
|
||||
backends := provider(setup.Environment)
|
||||
destinationBackend, err := backends.openDestination(ctx, destination)
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
defer closeBackend(destinationBackend)
|
||||
|
||||
report, err := executePrune(ctx, destinationBackend, pipeline, destination, options)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err := WritePruneReport(options.Stdout, options.OutputFormat, report); err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func requirePruneScope(options PruneOptions) error {
|
||||
if options.PipelineID == "" {
|
||||
return fmt.Errorf("pipeline id is required")
|
||||
}
|
||||
if options.DestinationID == "" {
|
||||
return fmt.Errorf("destination id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executePrune(ctx context.Context, backend storage.Backend, pipeline config.Pipeline, destination config.Destination, options PruneOptions) (PruneReport, error) {
|
||||
now := options.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
|
||||
statePath, err := storage.StatePath("")
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
data, err := backend.ReadFile(ctx, statePath)
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
document, err := state.ParseDocument(data)
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
|
||||
plan, err := PlanPrune(document, destination.Retention.Prune, PrunePlanOptions{
|
||||
PipelineID: pipeline.ID,
|
||||
DestinationID: destination.ID,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return PruneReport{}, err
|
||||
}
|
||||
report := PruneReport{
|
||||
PipelineID: pipeline.ID,
|
||||
DestinationID: destination.ID,
|
||||
Backend: destination.Backend,
|
||||
RootPath: destinationRootPath(destination),
|
||||
OwnerScope: plan.OwnerScope,
|
||||
Enabled: plan.Enabled,
|
||||
CheckedCount: plan.CheckedCount,
|
||||
PlannedOutputs: plan.PrunedOutputs,
|
||||
DeletedOutputs: []PruneOutputRecord{},
|
||||
PreservedOutputs: plan.PreservedOutputs,
|
||||
DryRun: options.DryRun,
|
||||
}
|
||||
report.WouldChange = options.DryRun && len(report.PlannedOutputs) > 0
|
||||
if options.DryRun || len(report.PlannedOutputs) == 0 {
|
||||
return report, nil
|
||||
}
|
||||
|
||||
deletedPaths := make([]string, 0, len(report.PlannedOutputs))
|
||||
for _, output := range report.PlannedOutputs {
|
||||
err := backend.DeleteManagedOutputs(ctx, "", []string{output.Path}, storage.DeleteOptions{
|
||||
IgnoreMissing: true,
|
||||
PruneEmptyDirs: true,
|
||||
})
|
||||
if err != nil {
|
||||
failed := output
|
||||
report.FailedOutput = &failed
|
||||
if len(deletedPaths) > 0 {
|
||||
changed, writeErr := removePrunedStateRecords(ctx, backend, statePath, document, state.CurrentOwnerScope(pipeline.ID, destination.ID), deletedPaths, now)
|
||||
report.StateChanged = changed
|
||||
report.DeletedOutputs = report.PlannedOutputs[:len(deletedPaths)]
|
||||
if writeErr != nil {
|
||||
return report, writeErr
|
||||
}
|
||||
}
|
||||
return report, err
|
||||
}
|
||||
deletedPaths = append(deletedPaths, output.Path)
|
||||
}
|
||||
|
||||
changed, err := removePrunedStateRecords(ctx, backend, statePath, document, state.CurrentOwnerScope(pipeline.ID, destination.ID), deletedPaths, now)
|
||||
report.StateChanged = changed
|
||||
report.DeletedOutputs = report.PlannedOutputs
|
||||
return report, err
|
||||
}
|
||||
|
||||
func removePrunedStateRecords(ctx context.Context, backend storage.Backend, statePath string, document state.StateDocument, scope state.OwnerScope, paths []string, now time.Time) (bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if document.Catalog != nil {
|
||||
next, changed := state.RemoveMissingCatalogOwnerOutputs(*document.Catalog, scope, paths)
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
next.UpdatedAt = now
|
||||
if err := state.ValidateCatalog(next); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, writeRepairedState(ctx, backend, statePath, next)
|
||||
}
|
||||
return false, unsupportedStateDocumentError(document)
|
||||
}
|
||||
|
||||
func PlanPrune(document state.StateDocument, policy config.PrunePolicy, options PrunePlanOptions) (PrunePlanReport, error) {
|
||||
scope := state.CurrentOwnerScope(options.PipelineID, options.DestinationID)
|
||||
report := PrunePlanReport{
|
||||
PipelineID: options.PipelineID,
|
||||
DestinationID: options.DestinationID,
|
||||
OwnerScope: PruneOwnerScope{PipelineID: scope.PipelineID, DestinationID: scope.DestinationID},
|
||||
Enabled: policy.Enabled,
|
||||
PrunedOutputs: []PruneOutputRecord{},
|
||||
PreservedOutputs: []PruneOutputRecord{},
|
||||
}
|
||||
if !policy.Enabled {
|
||||
return report, nil
|
||||
}
|
||||
|
||||
candidates, err := pruneCandidatesForDocument(document, scope)
|
||||
if err != nil {
|
||||
return PrunePlanReport{}, err
|
||||
}
|
||||
report.CheckedCount = len(candidates)
|
||||
plan := state.PlanPrune(candidates, state.PrunePlanOptions{
|
||||
Now: options.Now,
|
||||
OlderThan: pruneOlderThan(policy),
|
||||
KeepLatest: policy.KeepLatest,
|
||||
})
|
||||
report.PrunedOutputs = pruneOutputRecords(plan.Pruned)
|
||||
report.PreservedOutputs = pruneOutputRecords(plan.Preserved)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func pruneCandidatesForDocument(document state.StateDocument, scope state.OwnerScope) ([]state.PruneCandidate, error) {
|
||||
if document.Catalog != nil {
|
||||
return state.CatalogPruneCandidates(*document.Catalog, scope), nil
|
||||
}
|
||||
return nil, unsupportedStateDocumentError(document)
|
||||
}
|
||||
|
||||
func unsupportedStateDocumentError(document state.StateDocument) error {
|
||||
if document.SupersededLegacy != nil {
|
||||
return fmt.Errorf("destination state schema_version %d is superseded legacy state", document.SupersededLegacy.SchemaVersion)
|
||||
}
|
||||
return fmt.Errorf("destination state document is empty")
|
||||
}
|
||||
|
||||
func pruneOlderThan(policy config.PrunePolicy) *time.Duration {
|
||||
if policy.OlderThan == nil {
|
||||
return nil
|
||||
}
|
||||
duration := policy.OlderThan.AsDuration()
|
||||
return &duration
|
||||
}
|
||||
|
||||
func pruneOutputRecords(candidates []state.PruneCandidate) []PruneOutputRecord {
|
||||
records := make([]PruneOutputRecord, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
var owner *PruneOwnerScope
|
||||
if candidate.Owner != nil {
|
||||
owner = &PruneOwnerScope{
|
||||
PipelineID: candidate.Owner.PipelineID,
|
||||
DestinationID: candidate.Owner.DestinationID,
|
||||
}
|
||||
}
|
||||
records = append(records, PruneOutputRecord{
|
||||
Path: candidate.Path,
|
||||
UpdatedAt: candidate.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
Owner: owner,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func WritePruneReport(w io.Writer, format OutputFormat, report PruneReport) error {
|
||||
if IsJSONOutput(format) {
|
||||
return WriteJSONEnvelope(w, "prune", true, nil, report, nil)
|
||||
}
|
||||
return writePruneReportText(w, report)
|
||||
}
|
||||
|
||||
func writePruneReportText(w io.Writer, report PruneReport) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
status := "unchanged"
|
||||
if report.StateChanged {
|
||||
status = "changed"
|
||||
} else if report.WouldChange {
|
||||
status = "would_change"
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "Prune: pipeline=%s destination=%s backend=%s root=%s status=%s checked=%d planned=%d deleted=%d preserved=%d dry_run=%t\n",
|
||||
report.PipelineID,
|
||||
report.DestinationID,
|
||||
report.Backend,
|
||||
report.RootPath,
|
||||
status,
|
||||
report.CheckedCount,
|
||||
len(report.PlannedOutputs),
|
||||
len(report.DeletedOutputs),
|
||||
len(report.PreservedOutputs),
|
||||
report.DryRun,
|
||||
)
|
||||
return err
|
||||
}
|
||||
332
internal/app/prune_test.go
Normal file
332
internal/app/prune_test.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPlanPruneDisabledPolicy(t *testing.T) {
|
||||
document := state.StateDocument{Catalog: &state.CatalogState{}}
|
||||
report, err := PlanPrune(document, config.PrunePolicy{}, PrunePlanOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanPrune() error = %v", err)
|
||||
}
|
||||
if report.Enabled || report.CheckedCount != 0 || len(report.PrunedOutputs) != 0 {
|
||||
t.Fatalf("report = %#v, want disabled empty plan", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPruneCatalogOutputs(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
olderThan := config.Duration(48 * time.Hour)
|
||||
catalog := pruneCatalogState(now)
|
||||
|
||||
report, err := PlanPrune(state.StateDocument{Catalog: &catalog}, config.PrunePolicy{
|
||||
Enabled: true,
|
||||
OlderThan: &olderThan,
|
||||
}, PrunePlanOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanPrune() error = %v", err)
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.PrunedOutputs), "old.txt"; got != want {
|
||||
t.Fatalf("pruned = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.PreservedOutputs), "fresh.txt"; got != want {
|
||||
t.Fatalf("preserved = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPruneCatalogCurrentOwnerOnly(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
keepLatest := 0
|
||||
catalog := pruneCatalogState(now)
|
||||
|
||||
report, err := PlanPrune(state.StateDocument{Catalog: &catalog}, config.PrunePolicy{
|
||||
Enabled: true,
|
||||
KeepLatest: &keepLatest,
|
||||
}, PrunePlanOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanPrune() error = %v", err)
|
||||
}
|
||||
if got, want := report.CheckedCount, 2; got != want {
|
||||
t.Fatalf("checked count = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.PrunedOutputs), "old.txt,fresh.txt"; got != want {
|
||||
t.Fatalf("pruned = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDryRunReportsPlannedDeletesWithoutDeletingOrRewritingState(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
backend := fake.New()
|
||||
cfg := pruneS3Config(t, pruneOlderThanPolicy(48*time.Hour))
|
||||
original := pruneCatalogState(now)
|
||||
writeFakeCatalogState(t, backend, original)
|
||||
testutil.WriteFakeFile(t, backend, "unmanaged.txt", "keep")
|
||||
|
||||
report, err := pruneConfigWithBackendFactory(context.Background(), cfg, PruneOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
DryRun: true,
|
||||
Now: now,
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("pruneConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if !report.WouldChange || report.StateChanged || len(report.DeletedOutputs) != 0 {
|
||||
t.Fatalf("report would_change=%t state_changed=%t deleted=%d, want dry-run only", report.WouldChange, report.StateChanged, len(report.DeletedOutputs))
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.PlannedOutputs), "old.txt"; got != want {
|
||||
t.Fatalf("planned outputs = %q, want %q", got, want)
|
||||
}
|
||||
testutil.AssertFakeFile(t, backend, "old.txt", "managed")
|
||||
testutil.AssertFakeFile(t, backend, "fresh.txt", "managed")
|
||||
testutil.AssertFakeFile(t, backend, "unmanaged.txt", "keep")
|
||||
catalog := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "old.txt,fresh.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want original outputs", got)
|
||||
}
|
||||
if !catalog.UpdatedAt.Equal(original.UpdatedAt) {
|
||||
t.Fatalf("state updated_at = %s, want original %s", catalog.UpdatedAt, original.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneApplyDeletesOnlyManagedOutputsAndUpdatesState(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
backend := fake.New()
|
||||
cfg := pruneS3Config(t, pruneOlderThanPolicy(48*time.Hour))
|
||||
writeFakeCatalogState(t, backend, pruneCatalogState(now))
|
||||
testutil.WriteFakeFile(t, backend, "unmanaged.txt", "keep")
|
||||
|
||||
report, err := pruneConfigWithBackendFactory(context.Background(), cfg, PruneOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Now: now,
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("pruneConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if !report.StateChanged || report.WouldChange {
|
||||
t.Fatalf("report state_changed=%t would_change=%t, want applied change", report.StateChanged, report.WouldChange)
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.DeletedOutputs), "old.txt"; got != want {
|
||||
t.Fatalf("deleted outputs = %q, want %q", got, want)
|
||||
}
|
||||
testutil.AssertFakeMissing(t, backend, "old.txt")
|
||||
testutil.AssertFakeFile(t, backend, "fresh.txt", "managed")
|
||||
testutil.AssertFakeFile(t, backend, "html.txt", "managed")
|
||||
testutil.AssertFakeFile(t, backend, "unmanaged.txt", "keep")
|
||||
assertFakeStateExists(t, backend)
|
||||
catalog := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "fresh.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want fresh.txt", got)
|
||||
}
|
||||
if catalog.SchemaVersion != state.CatalogSchemaVersion {
|
||||
t.Fatalf("state schema_version = %d, want %d", catalog.SchemaVersion, state.CatalogSchemaVersion)
|
||||
}
|
||||
if !catalog.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("state updated_at = %s, want %s", catalog.UpdatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneApplyPreservesStateForFailedDeletes(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
backend := fake.New()
|
||||
keepLatest := 0
|
||||
cfg := pruneS3Config(t, config.PrunePolicy{Enabled: true, KeepLatest: &keepLatest})
|
||||
writeFakeCatalogState(t, backend, pruneCatalogState(now))
|
||||
failingBackend := failingDeleteBackend{Backend: backend, failPath: "fresh.txt"}
|
||||
|
||||
report, err := pruneConfigWithBackendFactory(context.Background(), cfg, PruneOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Now: now,
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": failingBackend}))
|
||||
if err == nil {
|
||||
t.Fatal("pruneConfigWithBackendFactory() error = nil, want delete failure")
|
||||
}
|
||||
if report.FailedOutput == nil || report.FailedOutput.Path != "fresh.txt" {
|
||||
t.Fatalf("failed output = %#v, want fresh.txt", report.FailedOutput)
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.DeletedOutputs), "old.txt"; got != want {
|
||||
t.Fatalf("deleted outputs = %q, want %q", got, want)
|
||||
}
|
||||
testutil.AssertFakeMissing(t, backend, "old.txt")
|
||||
testutil.AssertFakeFile(t, backend, "fresh.txt", "managed")
|
||||
assertFakeStateExists(t, backend)
|
||||
catalog := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "fresh.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want only failed output preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrunePreservesOtherOwnersWhenScopedToCurrentOwner(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
backend := fake.New()
|
||||
cfg := pruneS3Config(t, pruneOlderThanPolicy(48*time.Hour))
|
||||
writeFakeCatalogState(t, backend, pruneCatalogState(now))
|
||||
testutil.WriteFakeFile(t, backend, "unmanaged.txt", "keep")
|
||||
|
||||
report, err := pruneConfigWithBackendFactory(context.Background(), cfg, PruneOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Now: now,
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("pruneConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if got, want := pruneRecordPaths(report.DeletedOutputs), "old.txt"; got != want {
|
||||
t.Fatalf("deleted outputs = %q, want %q", got, want)
|
||||
}
|
||||
testutil.AssertFakeMissing(t, backend, "old.txt")
|
||||
testutil.AssertFakeFile(t, backend, "html.txt", "managed")
|
||||
testutil.AssertFakeFile(t, backend, "unmanaged.txt", "keep")
|
||||
catalog := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "fresh.txt,html.txt" {
|
||||
t.Fatalf("catalog outputs = %q, want other owner output preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func pruneCatalogState(now time.Time) state.CatalogState {
|
||||
manifest := testutil.ValidManifest(testutil.BundleOptions{})
|
||||
createdAt := now.Add(-96 * time.Hour)
|
||||
source := state.CatalogSourceIdentity{ID: manifest.ID, Digest: manifest.Digest, Created: manifest.Created}
|
||||
return state.CatalogState{
|
||||
SchemaVersion: state.CatalogSchemaVersion,
|
||||
DistributorVersion: "test",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
State: state.StatePolicy{Mode: state.StateModeCatalog},
|
||||
Outputs: []state.CatalogOutputFile{{
|
||||
Path: "old.txt",
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Source: source,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: manifest.Files[0].SHA256,
|
||||
Size: manifest.Files[0].Size,
|
||||
CreatedAt: now.Add(-96 * time.Hour),
|
||||
UpdatedAt: now.Add(-72 * time.Hour),
|
||||
}, {
|
||||
Path: "fresh.txt",
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Source: source,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: manifest.Files[0].SHA256,
|
||||
Size: manifest.Files[0].Size,
|
||||
CreatedAt: now.Add(-24 * time.Hour),
|
||||
UpdatedAt: now.Add(-24 * time.Hour),
|
||||
}, {
|
||||
Path: "html.txt",
|
||||
PipelineID: "reports",
|
||||
DestinationID: "html",
|
||||
Source: source,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: manifest.Files[1].SHA256,
|
||||
Size: manifest.Files[1].Size,
|
||||
CreatedAt: now.Add(-96 * time.Hour),
|
||||
UpdatedAt: now.Add(-72 * time.Hour),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func pruneRecordPaths(records []PruneOutputRecord) string {
|
||||
paths := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
paths = append(paths, record.Path)
|
||||
}
|
||||
return strings.Join(paths, ",")
|
||||
}
|
||||
|
||||
func pruneS3Config(t *testing.T, policy config.PrunePolicy) config.Config {
|
||||
t.Helper()
|
||||
cfg := config.Config{Pipelines: []config.Pipeline{{
|
||||
ID: "reports",
|
||||
Source: config.Backend{Backend: config.BackendLocal, Path: t.TempDir()},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
Backend: config.BackendS3,
|
||||
Bucket: "reports",
|
||||
Retention: config.RetentionPolicy{
|
||||
Prune: policy,
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
config.ApplyDefaults(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pruneOlderThanPolicy(duration time.Duration) config.PrunePolicy {
|
||||
value := config.Duration(duration)
|
||||
return config.PrunePolicy{
|
||||
Enabled: true,
|
||||
OlderThan: &value,
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeCatalogState(t *testing.T, backend *fake.Backend, catalog state.CatalogState) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(catalog, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal catalog state: %v", err)
|
||||
}
|
||||
testutil.WriteFakeFile(t, backend, storage.StateFileName, string(append(data, '\n')))
|
||||
for _, output := range catalog.Outputs {
|
||||
testutil.WriteFakeFile(t, backend, output.Path, "managed")
|
||||
}
|
||||
}
|
||||
|
||||
func readFakeCatalogState(t *testing.T, backend *fake.Backend) state.CatalogState {
|
||||
t.Helper()
|
||||
data, err := backend.ReadFile(context.Background(), storage.StateFileName)
|
||||
if err != nil {
|
||||
t.Fatalf("read catalog state: %v", err)
|
||||
}
|
||||
catalog, err := state.ParseCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse catalog state: %v", err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func assertFakeStateExists(t *testing.T, backend *fake.Backend) {
|
||||
t.Helper()
|
||||
if _, err := backend.Stat(context.Background(), storage.StateFileName); err != nil {
|
||||
t.Fatalf("state file stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingDeleteBackend struct {
|
||||
storage.Backend
|
||||
failPath string
|
||||
}
|
||||
|
||||
func (b failingDeleteBackend) DeleteManagedOutputs(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error {
|
||||
for _, path := range managedOutputPaths {
|
||||
if path == b.failPath {
|
||||
return storage.NewError(storage.OpDeleteManagedOutputs, "fake", path, storage.ErrPermission, nil)
|
||||
}
|
||||
}
|
||||
return b.Backend.DeleteManagedOutputs(ctx, bundlePath, managedOutputPaths, opts)
|
||||
}
|
||||
334
internal/app/reconcile_state.go
Normal file
334
internal/app/reconcile_state.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
const reconcileStateWalkLimit = 10000
|
||||
|
||||
type ReconcileStateOptions struct {
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
AllOwners bool
|
||||
DryRun bool
|
||||
Stdout io.Writer
|
||||
OutputFormat OutputFormat
|
||||
}
|
||||
|
||||
type ReconcileStateReport struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
DestinationID string `json:"destination_id"`
|
||||
Backend string `json:"backend"`
|
||||
RootPath string `json:"root_path"`
|
||||
StateSchema int `json:"state_schema"`
|
||||
OwnerScope *ReconcileStateOwnerScope `json:"owner_scope,omitempty"`
|
||||
CheckedCount int `json:"checked_count"`
|
||||
MissingManagedOutputs []ReconcileStatePath `json:"missing_managed_outputs"`
|
||||
UnmanagedEntries []ReconcileStateEntry `json:"unmanaged_entries"`
|
||||
Changed bool `json:"changed"`
|
||||
WouldChange bool `json:"would_change"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type ReconcileStateOwnerScope struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
DestinationID string `json:"destination_id"`
|
||||
AllOwners bool `json:"all_owners,omitempty"`
|
||||
}
|
||||
|
||||
type ReconcileStatePath struct {
|
||||
Path string `json:"path"`
|
||||
OwnerScope *ReconcileStateOwnerScope `json:"owner_scope,omitempty"`
|
||||
StorageStatus string `json:"storage_status"`
|
||||
}
|
||||
|
||||
type ReconcileStateEntry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
func ReconcileState(ctx context.Context, options ReconcileStateOptions) (ReconcileStateReport, error) {
|
||||
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
return reconcileStateSetup(ctx, setup, options)
|
||||
}
|
||||
|
||||
func reconcileStateConfigWithBackendFactory(ctx context.Context, cfg config.Config, options ReconcileStateOptions, provider backendFactoryProvider) (ReconcileStateReport, error) {
|
||||
setup, err := runtimeSetupFromConfig("", cfg)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
return reconcileStateSetupWithBackendFactory(ctx, setup, options, provider)
|
||||
}
|
||||
|
||||
func reconcileStateSetup(ctx context.Context, setup runtimeSetup, options ReconcileStateOptions) (ReconcileStateReport, error) {
|
||||
return reconcileStateSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
|
||||
}
|
||||
|
||||
func reconcileStateSetupWithBackendFactory(ctx context.Context, setup runtimeSetup, options ReconcileStateOptions, provider backendFactoryProvider) (ReconcileStateReport, error) {
|
||||
if err := requireReconcileStateScope(options); err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
pipeline, ok := findPipeline(setup.Config, options.PipelineID)
|
||||
if !ok {
|
||||
return ReconcileStateReport{}, PipelineNotFoundError{ID: options.PipelineID}
|
||||
}
|
||||
destination, ok := findDestination(pipeline, options.DestinationID)
|
||||
if !ok {
|
||||
return ReconcileStateReport{}, fmt.Errorf("pipeline %s destination %s not found", options.PipelineID, options.DestinationID)
|
||||
}
|
||||
|
||||
backends := provider(setup.Environment)
|
||||
destinationBackend, err := backends.openDestination(ctx, destination)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
defer closeBackend(destinationBackend)
|
||||
|
||||
report, err := buildReconcileStateReport(ctx, destinationBackend, pipeline, destination, options)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
if err := WriteReconcileStateReport(options.Stdout, options.OutputFormat, report); err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func requireReconcileStateScope(options ReconcileStateOptions) error {
|
||||
if options.PipelineID == "" {
|
||||
return fmt.Errorf("pipeline id is required")
|
||||
}
|
||||
if options.DestinationID == "" {
|
||||
return fmt.Errorf("destination id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findDestination(pipeline config.Pipeline, id string) (config.Destination, bool) {
|
||||
for _, destination := range pipeline.Destinations {
|
||||
if destination.ID == id {
|
||||
return destination, true
|
||||
}
|
||||
}
|
||||
return config.Destination{}, false
|
||||
}
|
||||
|
||||
func buildReconcileStateReport(ctx context.Context, backend storage.Backend, pipeline config.Pipeline, destination config.Destination, options ReconcileStateOptions) (ReconcileStateReport, error) {
|
||||
statePath, err := storage.StatePath("")
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
data, err := backend.ReadFile(ctx, statePath)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
document, err := state.ParseDocument(data)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
|
||||
report := ReconcileStateReport{
|
||||
PipelineID: pipeline.ID,
|
||||
DestinationID: destination.ID,
|
||||
Backend: destination.Backend,
|
||||
RootPath: destinationRootPath(destination),
|
||||
MissingManagedOutputs: []ReconcileStatePath{},
|
||||
UnmanagedEntries: []ReconcileStateEntry{},
|
||||
DryRun: options.DryRun,
|
||||
}
|
||||
scope := state.CurrentOwnerScope(pipeline.ID, destination.ID)
|
||||
if document.Catalog != nil {
|
||||
return reconcileCatalogState(ctx, backend, statePath, *document.Catalog, scope, report, options)
|
||||
}
|
||||
return ReconcileStateReport{}, unsupportedStateDocumentError(document)
|
||||
}
|
||||
|
||||
func reconcileCatalogState(ctx context.Context, backend storage.Backend, statePath string, catalog state.CatalogState, scope state.OwnerScope, report ReconcileStateReport, options ReconcileStateOptions) (ReconcileStateReport, error) {
|
||||
report.StateSchema = catalog.SchemaVersion
|
||||
report.OwnerScope = &ReconcileStateOwnerScope{
|
||||
PipelineID: scope.PipelineID,
|
||||
DestinationID: scope.DestinationID,
|
||||
AllOwners: options.AllOwners,
|
||||
}
|
||||
managed := state.CatalogManagedOutputPaths(catalog)
|
||||
outputs := catalog.Outputs
|
||||
if !options.AllOwners {
|
||||
outputs = state.CatalogOutputsForOwner(catalog.Outputs, scope)
|
||||
}
|
||||
missing, err := missingCatalogOutputs(ctx, backend, outputs)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
report.CheckedCount = len(outputs)
|
||||
report.MissingManagedOutputs = missing
|
||||
unmanaged, err := unmanagedEntries(ctx, backend, managed)
|
||||
if err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
report.UnmanagedEntries = unmanaged
|
||||
report.WouldChange = options.DryRun && len(missing) > 0
|
||||
|
||||
if !options.DryRun && len(missing) > 0 {
|
||||
missingPaths := missingReportPaths(missing)
|
||||
var next state.CatalogState
|
||||
var changed bool
|
||||
if options.AllOwners {
|
||||
next, changed = state.RemoveMissingCatalogOutputs(catalog, missingPaths)
|
||||
} else {
|
||||
next, changed = state.RemoveMissingCatalogOwnerOutputs(catalog, scope, missingPaths)
|
||||
}
|
||||
report.Changed = changed
|
||||
if changed {
|
||||
next.UpdatedAt = time.Now().UTC()
|
||||
if err := state.ValidateCatalog(next); err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
if err := writeRepairedState(ctx, backend, statePath, next); err != nil {
|
||||
return ReconcileStateReport{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func missingCatalogOutputs(ctx context.Context, backend storage.Backend, outputs []state.CatalogOutputFile) ([]ReconcileStatePath, error) {
|
||||
missing := make([]ReconcileStatePath, 0)
|
||||
for _, output := range outputs {
|
||||
if err := checkManagedOutput(ctx, backend, output.Path); err != nil {
|
||||
if storage.IsNotFound(err) {
|
||||
missing = append(missing, ReconcileStatePath{
|
||||
Path: output.Path,
|
||||
OwnerScope: &ReconcileStateOwnerScope{
|
||||
PipelineID: output.PipelineID,
|
||||
DestinationID: output.DestinationID,
|
||||
},
|
||||
StorageStatus: "missing",
|
||||
})
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
func checkManagedOutput(ctx context.Context, backend storage.Backend, path string) error {
|
||||
_, err := backend.Stat(ctx, path)
|
||||
return err
|
||||
}
|
||||
|
||||
func unmanagedEntries(ctx context.Context, backend storage.Backend, managedPaths []string) ([]ReconcileStateEntry, error) {
|
||||
managed := make(map[string]struct{}, len(managedPaths)+1)
|
||||
for _, path := range managedPaths {
|
||||
managed[path] = struct{}{}
|
||||
}
|
||||
managed[storage.StateFileName] = struct{}{}
|
||||
|
||||
entries := make([]ReconcileStateEntry, 0)
|
||||
err := backend.Walk(ctx, "", storage.WalkOptions{Recursive: true, Limit: reconcileStateWalkLimit}, func(entry storage.Entry) error {
|
||||
if entry.Type == storage.EntryTypeDirectory {
|
||||
return nil
|
||||
}
|
||||
if _, ok := managed[entry.Path]; ok {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, ReconcileStateEntry{
|
||||
Path: entry.Path,
|
||||
Type: string(entry.Type),
|
||||
Size: entry.Size,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Path < entries[j].Path
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func missingReportPaths(missing []ReconcileStatePath) []string {
|
||||
paths := make([]string, 0, len(missing))
|
||||
for _, item := range missing {
|
||||
paths = append(paths, item.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func writeRepairedState(ctx context.Context, backend storage.Backend, path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = backend.WriteFile(ctx, path, data, storage.WriteOptions{Overwrite: true, PreferAtomic: true})
|
||||
return err
|
||||
}
|
||||
|
||||
func destinationRootPath(destination config.Destination) string {
|
||||
switch destination.Backend {
|
||||
case config.BackendS3:
|
||||
if destination.Prefix == "" {
|
||||
return "."
|
||||
}
|
||||
return destination.Prefix
|
||||
default:
|
||||
if destination.Path == "" {
|
||||
return "."
|
||||
}
|
||||
return destination.Path
|
||||
}
|
||||
}
|
||||
|
||||
func WriteReconcileStateReport(w io.Writer, format OutputFormat, report ReconcileStateReport) error {
|
||||
if IsJSONOutput(format) {
|
||||
return WriteJSONEnvelope(w, "reconcile-state", true, nil, report, nil)
|
||||
}
|
||||
return writeReconcileStateReportText(w, report)
|
||||
}
|
||||
|
||||
func writeReconcileStateReportText(w io.Writer, report ReconcileStateReport) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
status := "unchanged"
|
||||
if report.Changed {
|
||||
status = "changed"
|
||||
} else if report.WouldChange {
|
||||
status = "would_change"
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "Reconcile state: pipeline=%s destination=%s backend=%s root=%s status=%s checked=%d missing=%d unmanaged=%d dry_run=%t\n",
|
||||
report.PipelineID,
|
||||
report.DestinationID,
|
||||
report.Backend,
|
||||
report.RootPath,
|
||||
status,
|
||||
report.CheckedCount,
|
||||
len(report.MissingManagedOutputs),
|
||||
len(report.UnmanagedEntries),
|
||||
report.DryRun,
|
||||
)
|
||||
return err
|
||||
}
|
||||
181
internal/app/reconcile_state_test.go
Normal file
181
internal/app/reconcile_state_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestReconcileStateDryRunReportsMissingManagedOutputsWithoutRewrite(t *testing.T) {
|
||||
backend := fake.New()
|
||||
cfg := reconcileStateS3Config(t)
|
||||
catalog := pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC))
|
||||
writeFakeCatalogState(t, backend, catalog)
|
||||
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"fresh.txt"}, storage.DeleteOptions{}); err != nil {
|
||||
t.Fatalf("delete managed output: %v", err)
|
||||
}
|
||||
testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged")
|
||||
|
||||
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
DryRun: true,
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if !report.WouldChange || report.Changed {
|
||||
t.Fatalf("report changed=%t would_change=%t, want dry-run pending change", report.Changed, report.WouldChange)
|
||||
}
|
||||
if got := reportPathList(report.MissingManagedOutputs); got != "fresh.txt" {
|
||||
t.Fatalf("missing outputs = %q, want fresh.txt", got)
|
||||
}
|
||||
if got := entryPathList(report.UnmanagedEntries); got != "extra.txt" {
|
||||
t.Fatalf("unmanaged entries = %q, want extra.txt", got)
|
||||
}
|
||||
repaired := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "old.txt,fresh.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want original outputs", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileStateApplyRemovesMissingRecordsAndPreservesUnmanagedFiles(t *testing.T) {
|
||||
backend := fake.New()
|
||||
cfg := reconcileStateS3Config(t)
|
||||
writeFakeCatalogState(t, backend, pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)))
|
||||
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"fresh.txt"}, storage.DeleteOptions{}); err != nil {
|
||||
t.Fatalf("delete managed output: %v", err)
|
||||
}
|
||||
testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged")
|
||||
|
||||
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if !report.Changed || report.WouldChange {
|
||||
t.Fatalf("report changed=%t would_change=%t, want applied change", report.Changed, report.WouldChange)
|
||||
}
|
||||
repaired := readFakeCatalogState(t, backend)
|
||||
if err := state.ValidateCatalog(repaired); err != nil {
|
||||
t.Fatalf("ValidateCatalog() repaired state error = %v", err)
|
||||
}
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "old.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want old.txt,html.txt", got)
|
||||
}
|
||||
if repaired.SchemaVersion != state.CatalogSchemaVersion {
|
||||
t.Fatalf("state schema_version = %d, want %d", repaired.SchemaVersion, state.CatalogSchemaVersion)
|
||||
}
|
||||
testutil.AssertFakeFile(t, backend, "extra.txt", "unmanaged")
|
||||
}
|
||||
|
||||
func TestReconcileStateInvalidStateFailsWithoutRewrite(t *testing.T) {
|
||||
backend := fake.New()
|
||||
cfg := reconcileStateS3Config(t)
|
||||
invalid := `{"schema_version":2,"pipeline_id":"reports"}`
|
||||
testutil.WriteFakeFile(t, backend, storage.StateFileName, invalid)
|
||||
|
||||
_, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err == nil {
|
||||
t.Fatal("reconcileStateConfigWithBackendFactory() error = nil, want invalid state error")
|
||||
}
|
||||
data, readErr := backend.ReadFile(context.Background(), storage.StateFileName)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read invalid state: %v", readErr)
|
||||
}
|
||||
if string(data) != invalid {
|
||||
t.Fatalf("state data = %q, want original invalid data", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileStateOwnerScopeRepairsCurrentOwnerOnly(t *testing.T) {
|
||||
backend := fake.New()
|
||||
cfg := reconcileStateS3Config(t)
|
||||
writeFakeCatalogState(t, backend, pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)))
|
||||
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"old.txt", "html.txt"}, storage.DeleteOptions{}); err != nil {
|
||||
t.Fatalf("delete managed outputs: %v", err)
|
||||
}
|
||||
|
||||
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if !report.Changed {
|
||||
t.Fatal("report changed = false, want true")
|
||||
}
|
||||
repaired := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "fresh.txt,html.txt" {
|
||||
t.Fatalf("catalog outputs = %q, want other owner output preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileStateAllOwnersRepairsEveryOwner(t *testing.T) {
|
||||
backend := fake.New()
|
||||
cfg := reconcileStateS3Config(t)
|
||||
writeFakeCatalogState(t, backend, pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)))
|
||||
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"old.txt", "html.txt"}, storage.DeleteOptions{}); err != nil {
|
||||
t.Fatalf("delete managed outputs: %v", err)
|
||||
}
|
||||
|
||||
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
AllOwners: true,
|
||||
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
|
||||
}
|
||||
if !report.Changed || report.CheckedCount != 3 {
|
||||
t.Fatalf("report changed=%t checked=%d, want all-owner repair", report.Changed, report.CheckedCount)
|
||||
}
|
||||
repaired := readFakeCatalogState(t, backend)
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "fresh.txt" {
|
||||
t.Fatalf("catalog outputs = %q, want fresh.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileStateS3Config(t *testing.T) config.Config {
|
||||
t.Helper()
|
||||
cfg := config.Config{Pipelines: []config.Pipeline{{
|
||||
ID: "reports",
|
||||
Source: config.Backend{Backend: config.BackendLocal, Path: t.TempDir()},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
Backend: config.BackendS3,
|
||||
Bucket: "reports",
|
||||
}},
|
||||
}}}
|
||||
config.ApplyDefaults(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func reportPathList(paths []ReconcileStatePath) string {
|
||||
values := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
values = append(values, path.Path)
|
||||
}
|
||||
return strings.Join(values, ",")
|
||||
}
|
||||
|
||||
func entryPathList(entries []ReconcileStateEntry) string {
|
||||
values := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
values = append(values, entry.Path)
|
||||
}
|
||||
return strings.Join(values, ",")
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -46,15 +45,11 @@ func Run(ctx context.Context, options RunOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := options.ConfigPath
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath
|
||||
}
|
||||
cfg, err := config.LoadFile(configPath)
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runConfig(ctx, cfg, options)
|
||||
return runSetup(ctx, setup, options)
|
||||
}
|
||||
|
||||
func RunPipeline(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
@@ -62,15 +57,11 @@ func RunPipeline(ctx context.Context, options RunPipelineOptions) (RunReport, er
|
||||
return RunReport{}, err
|
||||
}
|
||||
|
||||
configPath := options.ConfigPath
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath
|
||||
}
|
||||
cfg, err := config.LoadFile(configPath)
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return RunReport{}, err
|
||||
}
|
||||
return runPipelineConfig(ctx, cfg, options)
|
||||
return runPipelineSetup(ctx, setup, options)
|
||||
}
|
||||
|
||||
func RunPipelineWithLocalSource(ctx context.Context, options RunPipelineWithLocalSourceOptions) (RunReport, error) {
|
||||
@@ -81,57 +72,81 @@ func RunPipelineWithLocalSource(ctx context.Context, options RunPipelineWithLoca
|
||||
return RunReport{}, fmt.Errorf("source root is required")
|
||||
}
|
||||
|
||||
configPath := options.ConfigPath
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath
|
||||
}
|
||||
cfg, err := config.LoadFile(configPath)
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return RunReport{}, err
|
||||
}
|
||||
return runPipelineConfigWithLocalSource(ctx, cfg, options)
|
||||
return runPipelineSetupWithLocalSource(ctx, setup, options)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
func runPipelineConfig(ctx context.Context, cfg config.Config, options RunPipelineOptions) (RunReport, error) {
|
||||
return runPipelineConfigWithBackendFactory(ctx, cfg, options, newBackendFactoryWithEnvironment)
|
||||
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) {
|
||||
return runPipelineConfigWithLocalSourceAndBackendFactory(ctx, cfg, options, newBackendFactoryWithEnvironment)
|
||||
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) {
|
||||
pipeline, ok := findPipeline(cfg, options.PipelineID)
|
||||
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 buildRunReportWithBackendFactory(ctx, config.Config{
|
||||
Server: cfg.Server,
|
||||
Secrets: cfg.Secrets,
|
||||
Pipelines: []config.Pipeline{pipeline},
|
||||
}, RunOptions{
|
||||
return buildRunReportWithSetup(ctx, setup.withPipelines([]config.Pipeline{pipeline}), RunOptions{
|
||||
DryRun: options.DryRun,
|
||||
Force: options.Force,
|
||||
Notifier: options.Notifier,
|
||||
}, provider)
|
||||
}, provider, nil)
|
||||
}
|
||||
|
||||
func runPipelineConfigWithLocalSourceAndBackendFactory(ctx context.Context, cfg config.Config, options RunPipelineWithLocalSourceOptions, provider backendFactoryProvider) (RunReport, error) {
|
||||
pipeline, ok := findPipeline(cfg, options.PipelineID)
|
||||
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 buildRunReport(ctx, config.Config{
|
||||
Server: cfg.Server,
|
||||
Secrets: cfg.Secrets,
|
||||
Pipelines: []config.Pipeline{pipeline},
|
||||
}, RunOptions{
|
||||
return buildRunReportWithSetup(ctx, setup.withPipelines([]config.Pipeline{pipeline}), RunOptions{
|
||||
DryRun: options.DryRun,
|
||||
Force: options.Force,
|
||||
Notifier: options.Notifier,
|
||||
@@ -142,7 +157,19 @@ func runPipelineConfigWithLocalSourceAndBackendFactory(ctx context.Context, cfg
|
||||
}
|
||||
|
||||
func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error {
|
||||
report, err := buildRunReportWithBackendFactory(ctx, cfg, options, provider)
|
||||
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
|
||||
}
|
||||
@@ -153,7 +180,11 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
|
||||
}
|
||||
|
||||
func buildRunReportWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) (RunReport, error) {
|
||||
return buildRunReport(ctx, cfg, options, provider, nil)
|
||||
setup, err := runtimeSetupFromConfig("", cfg)
|
||||
if err != nil {
|
||||
return RunReport{}, err
|
||||
}
|
||||
return buildRunReportWithSetup(ctx, setup, options, provider, nil)
|
||||
}
|
||||
|
||||
type localSourceRoot struct {
|
||||
@@ -162,6 +193,14 @@ type localSourceRoot struct {
|
||||
}
|
||||
|
||||
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
|
||||
if notifier == nil {
|
||||
notifier = notify.Noop{}
|
||||
@@ -173,17 +212,17 @@ func buildRunReport(ctx context.Context, cfg config.Config, options RunOptions,
|
||||
Actions: []RunActionRecord{},
|
||||
}
|
||||
var failures runFailures
|
||||
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||
if err != nil {
|
||||
return report, err
|
||||
recorder := runReportRecorder{
|
||||
report: &report,
|
||||
summary: &summary,
|
||||
failures: &failures,
|
||||
}
|
||||
secretWarnings := secretConflictWarnings(secretLoad.Conflicts)
|
||||
report.PreambleWarnings = append(report.PreambleWarnings, secretWarnings...)
|
||||
report.addWarnings(secretWarnings)
|
||||
backends := provider(secretLoad.Environment)
|
||||
report.PreambleWarnings = append(report.PreambleWarnings, setup.Warnings...)
|
||||
report.addWarnings(setup.Warnings)
|
||||
backends := provider(setup.Environment)
|
||||
backends.readOnlyKnownHosts = options.DryRun
|
||||
transforms := newTransformRegistry()
|
||||
for _, pipeline := range cfg.Pipelines {
|
||||
for _, pipeline := range setup.Config.Pipelines {
|
||||
pipelineWarnings := sshWarnings(pipeline)
|
||||
report.addWarnings(pipelineWarnings)
|
||||
sourceBackend, bundles, sourceBackendName, err := openPipelineSource(ctx, backends, pipeline, sourceRoot)
|
||||
@@ -199,103 +238,18 @@ func buildRunReport(ctx context.Context, cfg config.Config, options RunOptions,
|
||||
})
|
||||
pipelineIndex := len(report.Pipelines) - 1
|
||||
for _, destination := range pipeline.Destinations {
|
||||
selections := selectDestinationBundles(destination, bundles)
|
||||
if isFixedPathDestination(destination) {
|
||||
summary.recordFixedPath()
|
||||
if options.DryRun {
|
||||
warning := fixedPathSelectionWarning(pipeline.ID, destination.ID, selections, len(bundles))
|
||||
report.addWarning(warning)
|
||||
report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, warningEvent(warning))
|
||||
}
|
||||
}
|
||||
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()
|
||||
report.Actions = append(report.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, selection.SourceBundle.RootRelativePath, err))
|
||||
report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, actionEvent(len(report.Actions)-1))
|
||||
}
|
||||
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)
|
||||
report.addWarning(warning)
|
||||
report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, warningEvent(warning))
|
||||
}
|
||||
}
|
||||
report.Actions = append(report.Actions, runActionFromPlan(destination.Backend, plan, err))
|
||||
report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, actionEvent(len(report.Actions)-1))
|
||||
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()
|
||||
processDestination(ctx, runDestinationRequest{
|
||||
options: options,
|
||||
notifier: notifier,
|
||||
backends: backends,
|
||||
transforms: transforms,
|
||||
pipeline: pipeline,
|
||||
pipelineIndex: pipelineIndex,
|
||||
sourceBackend: sourceBackend,
|
||||
bundles: bundles,
|
||||
destination: destination,
|
||||
recorder: &recorder,
|
||||
})
|
||||
}
|
||||
closeBackend(sourceBackend)
|
||||
}
|
||||
@@ -307,6 +261,12 @@ func buildRunReport(ctx context.Context, cfg config.Config, options RunOptions,
|
||||
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)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PipelineRunID string
|
||||
|
||||
type PipelineRunStatus string
|
||||
|
||||
const (
|
||||
PipelineRunRunning PipelineRunStatus = "running"
|
||||
PipelineRunSucceeded PipelineRunStatus = "succeeded"
|
||||
PipelineRunFailed PipelineRunStatus = "failed"
|
||||
)
|
||||
|
||||
type PipelineRunRecord struct {
|
||||
ID PipelineRunID `json:"id"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
Status PipelineRunStatus `json:"status"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
Report RunReport `json:"report,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DuplicatePipelineRunError struct {
|
||||
PipelineID string
|
||||
RunID PipelineRunID
|
||||
}
|
||||
|
||||
func (err DuplicatePipelineRunError) Error() string {
|
||||
if err.RunID == "" {
|
||||
return fmt.Sprintf("pipeline %q already has an active run", err.PipelineID)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q already has active run %s", err.PipelineID, err.RunID)
|
||||
}
|
||||
|
||||
func IsDuplicatePipelineRun(err error) bool {
|
||||
var duplicate DuplicatePipelineRunError
|
||||
return errors.As(err, &duplicate)
|
||||
}
|
||||
|
||||
type PipelineRunCoordinator struct {
|
||||
ctx context.Context
|
||||
run pipelineRunFunc
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
nextID uint64
|
||||
active map[string]PipelineRunRecord
|
||||
}
|
||||
|
||||
type pipelineRunFunc func(context.Context, RunPipelineOptions) (RunReport, error)
|
||||
|
||||
func NewPipelineRunCoordinator(ctx context.Context) *PipelineRunCoordinator {
|
||||
return newPipelineRunCoordinator(ctx, RunPipeline)
|
||||
}
|
||||
|
||||
func newPipelineRunCoordinator(ctx context.Context, run pipelineRunFunc) *PipelineRunCoordinator {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return &PipelineRunCoordinator{
|
||||
ctx: ctx,
|
||||
run: run,
|
||||
now: time.Now,
|
||||
active: map[string]PipelineRunRecord{},
|
||||
}
|
||||
}
|
||||
|
||||
func (coordinator *PipelineRunCoordinator) RunPipeline(ctx context.Context, options RunPipelineOptions) (PipelineRunRecord, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PipelineRunRecord{}, err
|
||||
}
|
||||
record, err := coordinator.admit(options.PipelineID)
|
||||
if err != nil {
|
||||
return PipelineRunRecord{}, err
|
||||
}
|
||||
defer coordinator.clear(options.PipelineID)
|
||||
|
||||
report, runErr := coordinator.run(coordinator.ctx, options)
|
||||
record.Report = report
|
||||
finishedAt := coordinator.now().UTC()
|
||||
record.FinishedAt = &finishedAt
|
||||
if runErr != nil {
|
||||
record.Status = PipelineRunFailed
|
||||
record.Error = runErr.Error()
|
||||
return record, runErr
|
||||
}
|
||||
record.Status = PipelineRunSucceeded
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (coordinator *PipelineRunCoordinator) admit(pipelineID string) (PipelineRunRecord, error) {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
if active, ok := coordinator.active[pipelineID]; ok {
|
||||
return PipelineRunRecord{}, DuplicatePipelineRunError{
|
||||
PipelineID: pipelineID,
|
||||
RunID: active.ID,
|
||||
}
|
||||
}
|
||||
coordinator.nextID++
|
||||
record := PipelineRunRecord{
|
||||
ID: PipelineRunID(fmt.Sprintf("run-%016d", coordinator.nextID)),
|
||||
PipelineID: pipelineID,
|
||||
Status: PipelineRunRunning,
|
||||
StartedAt: coordinator.now().UTC(),
|
||||
}
|
||||
coordinator.active[pipelineID] = record
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (coordinator *PipelineRunCoordinator) clear(pipelineID string) {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
delete(coordinator.active, pipelineID)
|
||||
}
|
||||
|
||||
func (coordinator *PipelineRunCoordinator) activeCount() int {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
return len(coordinator.active)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPipelineRunCoordinatorRejectsDuplicateActiveRun(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var startedOnce sync.Once
|
||||
coordinator := newPipelineRunCoordinator(context.Background(), func(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
startedOnce.Do(func() {
|
||||
close(started)
|
||||
})
|
||||
<-release
|
||||
return RunReport{}, nil
|
||||
})
|
||||
firstResult := make(chan runCoordinatorTestResult, 1)
|
||||
|
||||
go func() {
|
||||
record, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
firstResult <- runCoordinatorTestResult{record: record, err: err}
|
||||
}()
|
||||
waitForSignal(t, started, "first run to start")
|
||||
|
||||
_, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
if err == nil || !IsDuplicatePipelineRun(err) {
|
||||
t.Fatalf("RunPipeline() error = %v, want duplicate active run", err)
|
||||
}
|
||||
close(release)
|
||||
result := waitForRunResult(t, firstResult)
|
||||
if result.err != nil {
|
||||
t.Fatalf("first RunPipeline() error = %v", result.err)
|
||||
}
|
||||
if result.record.Status != PipelineRunSucceeded || result.record.ID == "" || result.record.FinishedAt == nil {
|
||||
t.Fatalf("first record = %#v, want succeeded completed record", result.record)
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 0 {
|
||||
t.Fatalf("active count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineRunCoordinatorAllowsDifferentActivePipelines(t *testing.T) {
|
||||
started := make(chan string, 2)
|
||||
release := make(chan struct{})
|
||||
coordinator := newPipelineRunCoordinator(context.Background(), func(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
started <- options.PipelineID
|
||||
<-release
|
||||
return RunReport{}, nil
|
||||
})
|
||||
firstResult := make(chan runCoordinatorTestResult, 1)
|
||||
secondResult := make(chan runCoordinatorTestResult, 1)
|
||||
|
||||
go func() {
|
||||
record, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports-one"})
|
||||
firstResult <- runCoordinatorTestResult{record: record, err: err}
|
||||
}()
|
||||
go func() {
|
||||
record, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports-two"})
|
||||
secondResult <- runCoordinatorTestResult{record: record, err: err}
|
||||
}()
|
||||
startedPipelines := map[string]bool{
|
||||
waitForPipelineID(t, started): true,
|
||||
waitForPipelineID(t, started): true,
|
||||
}
|
||||
if !startedPipelines["reports-one"] || !startedPipelines["reports-two"] {
|
||||
t.Fatalf("started pipelines = %#v, want both requested pipelines", startedPipelines)
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 2 {
|
||||
t.Fatalf("active count = %d, want 2", got)
|
||||
}
|
||||
|
||||
close(release)
|
||||
first := waitForRunResult(t, firstResult)
|
||||
second := waitForRunResult(t, secondResult)
|
||||
if first.err != nil || second.err != nil {
|
||||
t.Fatalf("RunPipeline() errors = %v, %v; want nil", first.err, second.err)
|
||||
}
|
||||
if first.record.ID == second.record.ID {
|
||||
t.Fatalf("run IDs matched: %q", first.record.ID)
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 0 {
|
||||
t.Fatalf("active count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineRunCoordinatorClearsActiveRunAfterSuccess(t *testing.T) {
|
||||
coordinator := newPipelineRunCoordinator(context.Background(), func(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
return RunReport{DryRun: options.DryRun}, nil
|
||||
})
|
||||
|
||||
first, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports", DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("first RunPipeline() error = %v", err)
|
||||
}
|
||||
second, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
if err != nil {
|
||||
t.Fatalf("second RunPipeline() error = %v", err)
|
||||
}
|
||||
if first.Status != PipelineRunSucceeded || second.Status != PipelineRunSucceeded {
|
||||
t.Fatalf("statuses = %s, %s; want succeeded", first.Status, second.Status)
|
||||
}
|
||||
if !first.Report.DryRun {
|
||||
t.Fatalf("first report dry_run = false, want true")
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 0 {
|
||||
t.Fatalf("active count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineRunCoordinatorClearsActiveRunAfterFailure(t *testing.T) {
|
||||
runError := errors.New("run failed")
|
||||
attempt := 0
|
||||
coordinator := newPipelineRunCoordinator(context.Background(), func(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
attempt++
|
||||
if attempt == 1 {
|
||||
return RunReport{}, runError
|
||||
}
|
||||
return RunReport{}, nil
|
||||
})
|
||||
|
||||
first, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
if !errors.Is(err, runError) {
|
||||
t.Fatalf("first RunPipeline() error = %v, want run failure", err)
|
||||
}
|
||||
if first.Status != PipelineRunFailed || first.Error != runError.Error() || first.FinishedAt == nil {
|
||||
t.Fatalf("first record = %#v, want failed completed record", first)
|
||||
}
|
||||
second, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
if err != nil {
|
||||
t.Fatalf("second RunPipeline() error = %v", err)
|
||||
}
|
||||
if second.Status != PipelineRunSucceeded {
|
||||
t.Fatalf("second status = %s, want succeeded", second.Status)
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 0 {
|
||||
t.Fatalf("active count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineRunCoordinatorClearsActiveRunAfterCancellation(t *testing.T) {
|
||||
runContext, cancel := context.WithCancel(context.Background())
|
||||
coordinator := newPipelineRunCoordinator(runContext, func(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
return RunReport{}, ctx.Err()
|
||||
})
|
||||
cancel()
|
||||
|
||||
record, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("RunPipeline() error = %v, want context canceled", err)
|
||||
}
|
||||
if record.Status != PipelineRunFailed || record.Error != context.Canceled.Error() {
|
||||
t.Fatalf("record = %#v, want failed cancellation record", record)
|
||||
}
|
||||
_, err = coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "reports"})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("second RunPipeline() error = %v, want context canceled", err)
|
||||
}
|
||||
if IsDuplicatePipelineRun(err) {
|
||||
t.Fatalf("second RunPipeline() error = %v, want cancellation instead of duplicate", err)
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 0 {
|
||||
t.Fatalf("active count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineRunCoordinatorUnknownPipelineDoesNotRemainActive(t *testing.T) {
|
||||
coordinator := newPipelineRunCoordinator(context.Background(), func(ctx context.Context, options RunPipelineOptions) (RunReport, error) {
|
||||
return RunReport{}, PipelineNotFoundError{ID: options.PipelineID}
|
||||
})
|
||||
|
||||
_, err := coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "missing"})
|
||||
if err == nil || !IsPipelineNotFound(err) {
|
||||
t.Fatalf("RunPipeline() error = %v, want pipeline not found", err)
|
||||
}
|
||||
_, err = coordinator.RunPipeline(context.Background(), RunPipelineOptions{PipelineID: "missing"})
|
||||
if err == nil || !IsPipelineNotFound(err) || IsDuplicatePipelineRun(err) {
|
||||
t.Fatalf("second RunPipeline() error = %v, want pipeline not found without duplicate", err)
|
||||
}
|
||||
if got := coordinator.activeCount(); got != 0 {
|
||||
t.Fatalf("active count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
type runCoordinatorTestResult struct {
|
||||
record PipelineRunRecord
|
||||
err error
|
||||
}
|
||||
|
||||
func waitForSignal(t *testing.T, signal <-chan struct{}, name string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPipelineID(t *testing.T, pipelineIDs <-chan string) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case pipelineID := <-pipelineIDs:
|
||||
return pipelineID
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for pipeline start")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRunResult(t *testing.T, results <-chan runCoordinatorTestResult) runCoordinatorTestResult {
|
||||
t.Helper()
|
||||
select {
|
||||
case result := <-results:
|
||||
return result
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for run result")
|
||||
return runCoordinatorTestResult{}
|
||||
}
|
||||
}
|
||||
167
internal/app/run_destination.go
Normal file
167
internal/app/run_destination.go
Normal file
@@ -0,0 +1,167 @@
|
||||
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,
|
||||
Workflow: request.destination.Workflow,
|
||||
Transformers: request.transforms,
|
||||
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 && isFixedPathWorkflowAction(plan.Action) {
|
||||
warning := fixedPathWorkflowWarning(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.summary.recordFailureAction(action.Action)
|
||||
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
|
||||
}
|
||||
if plan.Workflow == "" {
|
||||
plan.Workflow = destination.Workflow
|
||||
}
|
||||
return plan
|
||||
}
|
||||
@@ -6,20 +6,19 @@ import (
|
||||
)
|
||||
|
||||
func shouldNotify(action publish.Action) bool {
|
||||
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionForceReplace
|
||||
return action == publish.ActionPublishNew || action == publish.ActionUpsertAdditive || action == publish.ActionReplaceCatalog || action == publish.ActionForceReplace
|
||||
}
|
||||
|
||||
func notifyEvent(plan publish.Plan) notify.Event {
|
||||
outputs := make([]notify.Output, 0, len(plan.Outputs))
|
||||
for _, output := range plan.Outputs {
|
||||
stateOutput := output.StateOutputFile()
|
||||
outputs = append(outputs, notify.Output{
|
||||
Path: stateOutput.Path,
|
||||
Kind: stateOutput.Kind,
|
||||
SourcePath: stateOutput.SourcePath,
|
||||
Transform: stateOutput.Transform,
|
||||
SHA256: stateOutput.SHA256,
|
||||
Size: stateOutput.Size,
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
})
|
||||
}
|
||||
return notify.Event{
|
||||
|
||||
@@ -60,7 +60,7 @@ func writeRunActionLine(w io.Writer, action RunActionRecord) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s%s outputs=%s reason=%q\n", action.BundlePath, action.DestinationID, action.Backend, pathMappingRecordSummary(action), action.Action, workflowRecordSummary(action), outputRecordSummary(action.Outputs), action.Reason)
|
||||
}
|
||||
|
||||
func pathMappingRecordSummary(action RunActionRecord) string {
|
||||
@@ -70,6 +70,13 @@ func pathMappingRecordSummary(action RunActionRecord) string {
|
||||
return fmt.Sprintf(" path_mapping=fixed target=%s", action.DestinationPath)
|
||||
}
|
||||
|
||||
func workflowRecordSummary(action RunActionRecord) string {
|
||||
if action.Workflow == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" workflow=%s", action.Workflow)
|
||||
}
|
||||
|
||||
func outputRecordSummary(outputs []RunOutputRecord) string {
|
||||
if len(outputs) == 0 {
|
||||
return "none"
|
||||
@@ -136,6 +143,7 @@ type RunActionRecord struct {
|
||||
BundlePath string `json:"bundle_path"`
|
||||
DestinationPath string `json:"destination_path"`
|
||||
PathMapping string `json:"path_mapping,omitempty"`
|
||||
Workflow string `json:"workflow,omitempty"`
|
||||
Action string `json:"action"`
|
||||
PrimaryURL string `json:"primary_url,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
@@ -158,6 +166,13 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActi
|
||||
if destinationID == "" {
|
||||
destinationID = "unknown"
|
||||
}
|
||||
action := "error"
|
||||
outputs := []RunOutputRecord{}
|
||||
switch plan.Action {
|
||||
case publish.ActionFailUnmanaged, publish.ActionFailConflict:
|
||||
action = string(plan.Action)
|
||||
outputs = runOutputsFromPlan(plan.Outputs)
|
||||
}
|
||||
return RunActionRecord{
|
||||
PipelineID: plan.PipelineID,
|
||||
DestinationID: destinationID,
|
||||
@@ -166,10 +181,11 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActi
|
||||
BundlePath: storage.DisplayPath(plan.BundlePath),
|
||||
DestinationPath: storage.DisplayPath(plan.DestinationBundlePath),
|
||||
PathMapping: plan.PathMapping,
|
||||
Action: "error",
|
||||
Workflow: plan.Workflow,
|
||||
Action: action,
|
||||
PrimaryURL: plan.PrimaryURL,
|
||||
Reason: planErr.Error(),
|
||||
Outputs: []RunOutputRecord{},
|
||||
Outputs: outputs,
|
||||
}
|
||||
}
|
||||
return RunActionRecord{
|
||||
@@ -180,6 +196,7 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActi
|
||||
BundlePath: storage.DisplayPath(plan.BundlePath),
|
||||
DestinationPath: storage.DisplayPath(plan.DestinationBundlePath),
|
||||
PathMapping: plan.PathMapping,
|
||||
Workflow: plan.Workflow,
|
||||
Action: string(plan.Action),
|
||||
PrimaryURL: plan.PrimaryURL,
|
||||
Reason: plan.Reason,
|
||||
@@ -203,15 +220,14 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro
|
||||
func runOutputsFromPlan(outputs []publish.Output) []RunOutputRecord {
|
||||
results := make([]RunOutputRecord, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
stateOutput := output.StateOutputFile()
|
||||
results = append(results, RunOutputRecord{
|
||||
Path: stateOutput.Path,
|
||||
Kind: stateOutput.Kind,
|
||||
SourcePath: stateOutput.SourcePath,
|
||||
Transform: stateOutput.Transform,
|
||||
URL: stateOutput.URL,
|
||||
SHA256: stateOutput.SHA256,
|
||||
Size: stateOutput.Size,
|
||||
Path: output.DestinationPath,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
URL: output.URL,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
})
|
||||
}
|
||||
return results
|
||||
|
||||
@@ -62,12 +62,22 @@ func fixedPathSelectionWarning(pipelineID, destinationID string, selections []de
|
||||
return OutputWarning{Message: fmt.Sprintf("pipeline=%s destination=%s path_mapping=fixed candidates=%d selected_bundle=%s destination_bundle=.", pipelineID, destinationID, candidateCount, selected)}
|
||||
}
|
||||
|
||||
func isDestructiveFixedPathAction(action publish.Action) bool {
|
||||
return action == publish.ActionReplaceOlder || action == publish.ActionForceReplace
|
||||
func isFixedPathWorkflowAction(action publish.Action) bool {
|
||||
return action == publish.ActionUpsertAdditive || action == publish.ActionReplaceCatalog || action == publish.ActionForceReplace
|
||||
}
|
||||
|
||||
func fixedPathReplacementWarning(plan publish.Plan) OutputWarning {
|
||||
return OutputWarning{Message: fmt.Sprintf("pipeline=%s destination=%s path_mapping=fixed action=%s replaces destination root for selected_bundle=%s", plan.PipelineID, plan.DestinationID, plan.Action, storage.DisplayPath(plan.BundlePath))}
|
||||
func fixedPathWorkflowWarning(plan publish.Plan) OutputWarning {
|
||||
switch plan.Action {
|
||||
case publish.ActionReplaceCatalog:
|
||||
if plan.ClearDestinationRoot {
|
||||
return OutputWarning{Message: fmt.Sprintf("pipeline=%s destination=%s path_mapping=fixed workflow=%s action=%s clears destination root before writing selected_bundle=%s", plan.PipelineID, plan.DestinationID, plan.Workflow, plan.Action, storage.DisplayPath(plan.BundlePath))}
|
||||
}
|
||||
return OutputWarning{Message: fmt.Sprintf("pipeline=%s destination=%s path_mapping=fixed workflow=%s action=%s replaces current-owner catalog outputs for selected_bundle=%s", plan.PipelineID, plan.DestinationID, plan.Workflow, plan.Action, storage.DisplayPath(plan.BundlePath))}
|
||||
case publish.ActionUpsertAdditive:
|
||||
return OutputWarning{Message: fmt.Sprintf("pipeline=%s destination=%s path_mapping=fixed workflow=%s action=%s upserts planned outputs at destination root for selected_bundle=%s", plan.PipelineID, plan.DestinationID, plan.Workflow, plan.Action, storage.DisplayPath(plan.BundlePath))}
|
||||
default:
|
||||
return OutputWarning{Message: fmt.Sprintf("pipeline=%s destination=%s path_mapping=fixed workflow=%s action=%s writes selected_bundle=%s", plan.PipelineID, plan.DestinationID, plan.Workflow, plan.Action, storage.DisplayPath(plan.BundlePath))}
|
||||
}
|
||||
}
|
||||
|
||||
func destinationIDs(destinations []config.Destination) []string {
|
||||
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
)
|
||||
|
||||
type runSummary struct {
|
||||
dryRun bool
|
||||
planned int
|
||||
publishNew int
|
||||
replaceOlder int
|
||||
forceReplace int
|
||||
skipped int
|
||||
failures int
|
||||
fixedPath int
|
||||
dryRun bool
|
||||
planned int
|
||||
publishNew int
|
||||
upsertAdditive int
|
||||
replaceCatalog int
|
||||
skipSame int
|
||||
forceReplace int
|
||||
failUnmanaged int
|
||||
failConflict int
|
||||
failures int
|
||||
fixedPath int
|
||||
}
|
||||
|
||||
func (s *runSummary) recordPlan(action publish.Action) {
|
||||
@@ -22,12 +25,23 @@ func (s *runSummary) recordPlan(action publish.Action) {
|
||||
switch action {
|
||||
case publish.ActionPublishNew:
|
||||
s.publishNew++
|
||||
case publish.ActionReplaceOlder:
|
||||
s.replaceOlder++
|
||||
case publish.ActionUpsertAdditive:
|
||||
s.upsertAdditive++
|
||||
case publish.ActionReplaceCatalog:
|
||||
s.replaceCatalog++
|
||||
case publish.ActionForceReplace:
|
||||
s.forceReplace++
|
||||
case publish.ActionSkipSame, publish.ActionSkipDestinationNewer:
|
||||
s.skipped++
|
||||
case publish.ActionSkipSame:
|
||||
s.skipSame++
|
||||
}
|
||||
}
|
||||
|
||||
func (s *runSummary) recordFailureAction(action string) {
|
||||
switch action {
|
||||
case string(publish.ActionFailUnmanaged):
|
||||
s.failUnmanaged++
|
||||
case string(publish.ActionFailConflict):
|
||||
s.failConflict++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,19 +54,22 @@ func (s *runSummary) recordFixedPath() {
|
||||
}
|
||||
|
||||
type RunSummaryCounters struct {
|
||||
Status string `json:"status"`
|
||||
Planned int `json:"planned"`
|
||||
PublishNew int `json:"publish_new"`
|
||||
ReplaceOlder int `json:"replace_older"`
|
||||
ForceReplace int `json:"force_replace"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
FixedPath int `json:"fixed_path"`
|
||||
Status string `json:"status"`
|
||||
Planned int `json:"planned"`
|
||||
PublishNew int `json:"publish_new"`
|
||||
UpsertAdditive int `json:"upsert_additive"`
|
||||
ReplaceCatalog int `json:"replace_catalog"`
|
||||
SkipSame int `json:"skip_same"`
|
||||
ForceReplace int `json:"force_replace"`
|
||||
FailUnmanaged int `json:"fail_unmanaged"`
|
||||
FailConflict int `json:"fail_conflict"`
|
||||
Failed int `json:"failed"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
FixedPath int `json:"fixed_path"`
|
||||
}
|
||||
|
||||
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)
|
||||
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d upsert_additive=%d replace_catalog=%d skip_same=%d force_replace=%d fail_unmanaged=%d fail_conflict=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.UpsertAdditive, s.ReplaceCatalog, s.SkipSame, s.ForceReplace, s.FailUnmanaged, s.FailConflict, s.Failed, s.DryRun, s.FixedPath)
|
||||
}
|
||||
|
||||
func (s runSummary) Result() RunSummaryCounters {
|
||||
@@ -61,14 +78,17 @@ func (s runSummary) Result() RunSummaryCounters {
|
||||
status = "failed"
|
||||
}
|
||||
return RunSummaryCounters{
|
||||
Status: status,
|
||||
Planned: s.planned,
|
||||
PublishNew: s.publishNew,
|
||||
ReplaceOlder: s.replaceOlder,
|
||||
ForceReplace: s.forceReplace,
|
||||
Skipped: s.skipped,
|
||||
Failed: s.failures,
|
||||
DryRun: s.dryRun,
|
||||
FixedPath: s.fixedPath,
|
||||
Status: status,
|
||||
Planned: s.planned,
|
||||
PublishNew: s.publishNew,
|
||||
UpsertAdditive: s.upsertAdditive,
|
||||
ReplaceCatalog: s.replaceCatalog,
|
||||
SkipSame: s.skipSame,
|
||||
ForceReplace: s.forceReplace,
|
||||
FailUnmanaged: s.failUnmanaged,
|
||||
FailConflict: s.failConflict,
|
||||
Failed: s.failures,
|
||||
DryRun: s.dryRun,
|
||||
FixedPath: s.fixedPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ func TestRunDryRunPrintsConfigSummary(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Configured pipelines: 1",
|
||||
"- pipeline=reports source=local bundles=1 destinations=archive",
|
||||
"bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt",
|
||||
"Final status: ok planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true",
|
||||
"bundle=. destination=archive backend=local action=publish_new workflow=additive outputs=report.md,summary.txt",
|
||||
"Final status: ok planned=1 publish_new=1 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=0 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=true",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("Run() output = %q, want substring %q", output, want)
|
||||
@@ -210,14 +210,14 @@ func TestRunPublishesNewLocalBundle(t *testing.T) {
|
||||
if destinationState.PipelineID != "reports" || destinationState.DestinationID != "archive" {
|
||||
t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID)
|
||||
}
|
||||
if destinationState.Source.Manifest.ID != manifest.ID {
|
||||
t.Fatalf("state source id = %q, want %q", destinationState.Source.Manifest.ID, manifest.ID)
|
||||
if destinationState.SourceID != manifest.ID {
|
||||
t.Fatalf("state source id = %q, want %q", destinationState.SourceID, manifest.ID)
|
||||
}
|
||||
if got, want := len(destinationState.Outputs), 2; got != want {
|
||||
t.Fatalf("state output count = %d, want %d", got, want)
|
||||
}
|
||||
if destinationState.Links != nil || destinationState.Outputs[0].URL != "" {
|
||||
t.Fatalf("state links = %#v output URL=%q, want absent", destinationState.Links, destinationState.Outputs[0].URL)
|
||||
if destinationState.PrimaryURL != "" || destinationState.Outputs[0].URL != "" {
|
||||
t.Fatalf("state primary URL = %q output URL=%q, want absent", destinationState.PrimaryURL, destinationState.Outputs[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +292,6 @@ func TestRunPipelineWithLocalSourcePublishesToRegisteredDestinationBackends(t *t
|
||||
ID: "reports",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendHTTPUpload,
|
||||
Upload: config.HTTPUpload{TokenEnv: "UPLOAD_TOKEN"},
|
||||
},
|
||||
Destinations: []config.Destination{
|
||||
{
|
||||
@@ -309,6 +308,11 @@ func TestRunPipelineWithLocalSourcePublishesToRegisteredDestinationBackends(t *t
|
||||
},
|
||||
},
|
||||
}},
|
||||
UploadTokens: []config.UploadToken{{
|
||||
ID: "reporter",
|
||||
TokenEnv: "UPLOAD_TOKEN",
|
||||
AllowPipelines: []string{"reports"},
|
||||
}},
|
||||
}
|
||||
config.ApplyDefaults(&cfg)
|
||||
provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{
|
||||
@@ -358,8 +362,8 @@ func TestRunRecordsLinksForNestedBundlePath(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, "daily", "brentwood", storage.StateFileName))
|
||||
if destinationState.Links == nil || destinationState.Links.PrimaryURL != "https://reports.example.com/archive/daily/brentwood/report.md" {
|
||||
t.Fatalf("state links = %#v, want source primary URL", destinationState.Links)
|
||||
if destinationState.PrimaryURL != "https://reports.example.com/archive/daily/brentwood/report.md" {
|
||||
t.Fatalf("state primary URL = %q, want source primary URL", destinationState.PrimaryURL)
|
||||
}
|
||||
outputs := outputsByPath(destinationState.Outputs)
|
||||
if outputs["report.md"].URL != "https://reports.example.com/archive/daily/brentwood/report.md" {
|
||||
@@ -383,8 +387,8 @@ func TestRunRecordsLinksForFixedIndexDestination(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if destinationState.Links == nil || destinationState.Links.PrimaryURL != "https://reports.example.com/latest/" {
|
||||
t.Fatalf("state links = %#v, want fixed index primary URL", destinationState.Links)
|
||||
if destinationState.PrimaryURL != "https://reports.example.com/latest/" {
|
||||
t.Fatalf("state primary URL = %q, want fixed index primary URL", destinationState.PrimaryURL)
|
||||
}
|
||||
if got, want := len(destinationState.Outputs), 1; got != want {
|
||||
t.Fatalf("state output count = %d, want %d", got, want)
|
||||
@@ -423,8 +427,8 @@ func TestRunFixedPathPublishesNewestBundleAtDestinationRoot(t *testing.T) {
|
||||
t.Fatalf("nested new report stat error = %v, want not exist", err)
|
||||
}
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if destinationState.Source.Manifest.ID != "reports.new" {
|
||||
t.Fatalf("state source id = %q, want reports.new", destinationState.Source.Manifest.ID)
|
||||
if destinationState.SourceID != "reports.new" {
|
||||
t.Fatalf("state source id = %q, want reports.new", destinationState.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,8 +457,8 @@ func TestRunFixedPathTieBreaksByBundlePath(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if destinationState.Source.Manifest.ID != "reports.a" {
|
||||
t.Fatalf("state source id = %q, want reports.a", destinationState.Source.Manifest.ID)
|
||||
if destinationState.SourceID != "reports.a" {
|
||||
t.Fatalf("state source id = %q, want reports.a", destinationState.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +506,7 @@ func TestRunFixedPathDryRunWarnsForReplacement(t *testing.T) {
|
||||
{Path: "summary.txt", Data: "Old summary\n"},
|
||||
},
|
||||
})
|
||||
configPath := testutil.WriteLocalConfigWithPathMapping(t, sourceRoot, destinationRoot, config.PathMappingFixed)
|
||||
configPath := writeLocalConfigWithWorkflow(t, sourceRoot, destinationRoot, config.PathMappingFixed, config.WorkflowReplacement)
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
@@ -526,8 +530,9 @@ func TestRunFixedPathDryRunWarnsForReplacement(t *testing.T) {
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Warning: pipeline=reports destination=archive path_mapping=fixed action=replace_older replaces destination root for selected_bundle=new",
|
||||
"bundle=new destination=archive backend=local path_mapping=fixed target=. action=replace_older",
|
||||
"Warning: pipeline=reports destination=archive path_mapping=fixed workflow=replacement action=replace_catalog replaces current-owner catalog outputs for selected_bundle=new",
|
||||
"bundle=new destination=archive backend=local path_mapping=fixed target=. action=replace_catalog workflow=replacement outputs=report.md,summary.txt reason=\"\"",
|
||||
"replace_catalog=1",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stdout = %q, want substring %q", output, want)
|
||||
@@ -536,7 +541,7 @@ func TestRunFixedPathDryRunWarnsForReplacement(t *testing.T) {
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nOld.\n")
|
||||
}
|
||||
|
||||
func TestRunFixedPathReplacesOlderManagedState(t *testing.T) {
|
||||
func TestRunJSONIncludesWorkflowActionAndSummary(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "old", testBundleOptions{
|
||||
@@ -547,12 +552,10 @@ func TestRunFixedPathReplacesOlderManagedState(t *testing.T) {
|
||||
{Path: "summary.txt", Data: "Old summary\n"},
|
||||
},
|
||||
})
|
||||
configPath := testutil.WriteLocalConfigWithPathMapping(t, sourceRoot, destinationRoot, config.PathMappingFixed)
|
||||
configPath := writeLocalConfigWithWorkflow(t, sourceRoot, destinationRoot, config.PathMappingFixed, config.WorkflowReplacement)
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nOld.\n")
|
||||
|
||||
writeSourceBundle(t, sourceRoot, "new", testBundleOptions{
|
||||
ID: "reports.new",
|
||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||
@@ -562,44 +565,35 @@ func TestRunFixedPathReplacesOlderManagedState(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nNew.\n")
|
||||
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if destinationState.Source.Manifest.ID != "reports.new" {
|
||||
t.Fatalf("state source id = %q, want reports.new", destinationState.Source.Manifest.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFixedPathSkipsWhenDestinationStateIsNewer(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
newer := testutil.ValidManifest(testutil.BundleOptions{
|
||||
ID: "reports.newer",
|
||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||
})
|
||||
writeDestinationState(t, destinationRoot, "", newer)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nExisting.\n"), 0o600); err != nil {
|
||||
t.Fatalf("write existing report: %v", err)
|
||||
}
|
||||
writeSourceBundle(t, sourceRoot, "older", testBundleOptions{
|
||||
ID: "reports.older",
|
||||
Created: testutil.DefaultCreated,
|
||||
})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: testutil.WriteLocalConfigWithPathMapping(t, sourceRoot, destinationRoot, config.PathMappingFixed),
|
||||
Stdout: &stdout,
|
||||
ConfigPath: configPath,
|
||||
DryRun: true,
|
||||
Stdout: &stdout,
|
||||
OutputFormat: OutputFormatJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=skip_destination_newer") {
|
||||
t.Fatalf("stdout = %q, want skip_destination_newer", stdout.String())
|
||||
result := decodeAppResult(t, stdout.String())
|
||||
actions, ok := result["actions"].([]any)
|
||||
if !ok || len(actions) != 1 {
|
||||
t.Fatalf("actions = %#v, want one action", result["actions"])
|
||||
}
|
||||
action, ok := actions[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("action = %#v, want object", actions[0])
|
||||
}
|
||||
if action["action"] != "replace_catalog" || action["workflow"] != "replacement" || action["reason"] != nil {
|
||||
t.Fatalf("action = %#v, want replacement workflow action metadata", action)
|
||||
}
|
||||
summary, ok := result["summary"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("summary = %#v, want object", result["summary"])
|
||||
}
|
||||
if summary["replace_catalog"] != float64(1) || summary["upsert_additive"] != float64(0) || summary["force_replace"] != float64(0) {
|
||||
t.Fatalf("summary = %#v, want replacement workflow counter only", summary)
|
||||
}
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nExisting.\n")
|
||||
}
|
||||
|
||||
func TestRunFixedPathFailsUnmanagedWithoutForce(t *testing.T) {
|
||||
@@ -643,6 +637,10 @@ func TestRunFixedPathForceReplacementStaysWithinDestinationRoot(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged stat error = %v, want removed", err)
|
||||
}
|
||||
catalog := readLocalCatalogState(t, destinationRoot)
|
||||
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
|
||||
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
|
||||
@@ -655,14 +653,6 @@ func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
|
||||
{Path: "summary.txt", Data: "Old summary\n"},
|
||||
},
|
||||
})
|
||||
writeSourceBundle(t, localSourceRoot, "new", testBundleOptions{
|
||||
ID: "reports.new",
|
||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||
Files: []testFile{
|
||||
{Path: "report.md", Data: "# Report\nNew.\n"},
|
||||
{Path: "summary.txt", Data: "New summary\n"},
|
||||
},
|
||||
})
|
||||
s3Destination := fake.New()
|
||||
sshDestination := fake.New()
|
||||
cfg := config.Config{Pipelines: []config.Pipeline{{
|
||||
@@ -694,6 +684,30 @@ func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{}, provider); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
testutil.AssertFakeFile(t, s3Destination, "report.md", "# Report\nOld.\n")
|
||||
testutil.AssertFakeFile(t, sshDestination, "summary.txt", "Old summary\n")
|
||||
|
||||
writeSourceBundle(t, localSourceRoot, "new", testBundleOptions{
|
||||
ID: "reports.new",
|
||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||
Files: []testFile{
|
||||
{Path: "report.md", Data: "# Report\nNew.\n"},
|
||||
{Path: "summary.txt", Data: "New summary\n"},
|
||||
},
|
||||
})
|
||||
var stdout bytes.Buffer
|
||||
if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{Stdout: &stdout}, provider); err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"destination=object-latest backend=s3 path_mapping=fixed target=. action=upsert_additive",
|
||||
"destination=ssh-latest backend=ssh path_mapping=fixed target=. action=upsert_additive",
|
||||
"planned=2",
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("stdout = %q, want substring %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
testutil.AssertFakeFile(t, s3Destination, "report.md", "# Report\nNew.\n")
|
||||
testutil.AssertFakeFile(t, s3Destination, "summary.txt", "New summary\n")
|
||||
testutil.AssertFakeMissing(t, s3Destination, "new/report.md")
|
||||
@@ -762,17 +776,22 @@ func TestRunNotifiesGeneratedOutputMetadata(t *testing.T) {
|
||||
func TestRunNotifiesAfterReplacement(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
older := manifest
|
||||
older.Created = older.Created.Add(-time.Hour)
|
||||
writeDestinationState(t, destinationRoot, "", older)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
|
||||
t.Fatalf("write old output: %v", err)
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
configPath := writeLocalConfigWithWorkflow(t, sourceRoot, destinationRoot, config.PathMappingPreserveRelative, config.WorkflowReplacement)
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{
|
||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||
Files: []testFile{
|
||||
{Path: "report.md", Data: "# Report\nNew.\n"},
|
||||
{Path: "summary.txt", Data: "New summary\n"},
|
||||
},
|
||||
})
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
ConfigPath: configPath,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -781,8 +800,8 @@ func TestRunNotifiesAfterReplacement(t *testing.T) {
|
||||
if got, want := len(notifier.events), 1; got != want {
|
||||
t.Fatalf("notification count = %d, want %d", got, want)
|
||||
}
|
||||
if notifier.events[0].Action != "replace_older" {
|
||||
t.Fatalf("notification action = %q, want replace_older", notifier.events[0].Action)
|
||||
if notifier.events[0].Action != "replace_catalog" {
|
||||
t.Fatalf("notification action = %q, want replace_catalog", notifier.events[0].Action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,8 +908,8 @@ func TestBuildRunReportIncludesPartialFailures(t *testing.T) {
|
||||
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)
|
||||
if err := os.WriteFile(filepath.Join(firstDestination, "report.md"), []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged planned file: %v", err)
|
||||
}
|
||||
cfg, err := config.LoadFile(writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination))
|
||||
if err != nil {
|
||||
@@ -907,8 +926,8 @@ func TestBuildRunReportIncludesPartialFailures(t *testing.T) {
|
||||
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[0].DestinationID != "archive-one" || report.Actions[0].Action != "fail_unmanaged" || !strings.Contains(report.Actions[0].Reason, "fail_unmanaged") {
|
||||
t.Fatalf("first action = %#v, want archive-one unmanaged failure", 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])
|
||||
@@ -922,6 +941,50 @@ func TestBuildRunReportIncludesPartialFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -999,7 +1062,7 @@ func TestRunStillRunsAllConfiguredPipelines(t *testing.T) {
|
||||
testutil.AssertFile(t, filepath.Join(secondDestination, "report.md"), "# Report\nSunny.\n")
|
||||
}
|
||||
|
||||
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
|
||||
func TestRunNotifiesForAdditiveUpsert(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
@@ -1007,14 +1070,24 @@ func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{
|
||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||
Files: []testFile{
|
||||
{Path: "report.md", Data: "# Report\nNew.\n"},
|
||||
{Path: "summary.txt", Data: "New summary\n"},
|
||||
},
|
||||
})
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Notifier: notifier})
|
||||
if err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
if len(notifier.events) != 0 {
|
||||
t.Fatalf("notifications = %#v, want none", notifier.events)
|
||||
if got, want := len(notifier.events), 1; got != want {
|
||||
t.Fatalf("notification count = %d, want %d", got, want)
|
||||
}
|
||||
if notifier.events[0].Action != "upsert_additive" {
|
||||
t.Fatalf("notification action = %q, want upsert_additive", notifier.events[0].Action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,8 +1118,8 @@ func TestRunContinuesAfterDestinationFailure(t *testing.T) {
|
||||
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)
|
||||
if err := os.WriteFile(filepath.Join(firstDestination, "report.md"), []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged planned file: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
@@ -1062,9 +1135,9 @@ func TestRunContinuesAfterDestinationFailure(t *testing.T) {
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"destination=archive-one backend=local action=error",
|
||||
"destination=archive-two backend=local action=publish_new",
|
||||
"Final status: failed planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=1 dry_run=false",
|
||||
"destination=archive-one backend=local action=fail_unmanaged workflow=additive",
|
||||
"destination=archive-two backend=local action=publish_new workflow=additive",
|
||||
"Final status: failed planned=1 publish_new=1 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=0 fail_unmanaged=1 fail_conflict=0 failed=1 dry_run=false",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stdout = %q, want substring %q", output, want)
|
||||
@@ -1314,6 +1387,55 @@ func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
statePath := filepath.Join(destinationRoot, storage.StateFileName)
|
||||
stateBefore, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read state before second run: %v", err)
|
||||
}
|
||||
reportBefore, err := os.ReadFile(filepath.Join(destinationRoot, "report.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read report before second run: %v", err)
|
||||
}
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err = Run(context.Background(), RunOptions{ConfigPath: configPath, Stdout: &stdout, Notifier: notifier})
|
||||
if err != nil {
|
||||
t.Fatalf("second Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=skip_same") {
|
||||
t.Fatalf("stdout = %q, want skip_same", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Final status: ok planned=1 publish_new=0 upsert_additive=0 replace_catalog=0 skip_same=1") {
|
||||
t.Fatalf("stdout = %q, want skip_same summary", stdout.String())
|
||||
}
|
||||
stateAfter, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read state after second run: %v", err)
|
||||
}
|
||||
if string(stateAfter) != string(stateBefore) {
|
||||
t.Fatalf("state changed during skip")
|
||||
}
|
||||
reportAfter, err := os.ReadFile(filepath.Join(destinationRoot, "report.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read report after second run: %v", err)
|
||||
}
|
||||
if string(reportAfter) != string(reportBefore) {
|
||||
t.Fatalf("report changed during skip")
|
||||
}
|
||||
if got, want := len(notifier.events), 0; got != want {
|
||||
t.Fatalf("notification count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReplacementSkipsWhenDestinationStateMatches(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
configPath := writeLocalConfigWithWorkflow(t, sourceRoot, destinationRoot, config.PathMappingPreserveRelative, config.WorkflowReplacement)
|
||||
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||
t.Fatalf("first Run() error = %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Stdout: &stdout})
|
||||
@@ -1325,63 +1447,6 @@ func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReplacesOlderDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
older := manifest
|
||||
older.Created = older.Created.Add(-time.Hour)
|
||||
writeDestinationState(t, destinationRoot, "", older)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
|
||||
t.Fatalf("write old output: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=replace_older") {
|
||||
t.Fatalf("stdout = %q, want replace_older", stdout.String())
|
||||
}
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
}
|
||||
|
||||
func TestRunSkipsNewerDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
newer := manifest
|
||||
newer.Created = newer.Created.Add(time.Hour)
|
||||
writeDestinationState(t, destinationRoot, "", newer)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("newer\n"), 0o600); err != nil {
|
||||
t.Fatalf("write newer output: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), Stdout: &stdout})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=skip_destination_newer") {
|
||||
t.Fatalf("stdout = %q, want skip_destination_newer", stdout.String())
|
||||
}
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n")
|
||||
}
|
||||
|
||||
func TestRunFailsOnConflict(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
manifest.ID = "other.source"
|
||||
writeDestinationState(t, destinationRoot, "", manifest)
|
||||
|
||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)})
|
||||
if err == nil || !strings.Contains(err.Error(), "fail_conflict") {
|
||||
t.Fatalf("Run() error = %v, want fail_conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailsOnUnmanagedDestination(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
@@ -1416,10 +1481,50 @@ func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "action=force_replace") {
|
||||
t.Fatalf("stdout = %q, want force_replace", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Final status: ok planned=1 publish_new=0 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=1 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=false") {
|
||||
t.Fatalf("stdout = %q, want force_replace counter only", stdout.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged file stat error = %v, want not exist", err)
|
||||
}
|
||||
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
catalog := readLocalCatalogState(t, destinationRoot)
|
||||
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
|
||||
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunForceReplacementJSONOutput(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("old"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged file: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err := Run(context.Background(), RunOptions{
|
||||
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||
Force: true,
|
||||
Stdout: &stdout,
|
||||
OutputFormat: OutputFormatJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
result := decodeAppResult(t, stdout.String())
|
||||
actions, ok := result["actions"].([]any)
|
||||
if !ok || len(actions) != 1 {
|
||||
t.Fatalf("actions = %#v, want one action", result["actions"])
|
||||
}
|
||||
action, ok := actions[0].(map[string]any)
|
||||
if !ok || action["action"] != "force_replace" || action["workflow"] != "additive" {
|
||||
t.Fatalf("action = %#v, want force_replace additive", actions[0])
|
||||
}
|
||||
summary, ok := result["summary"].(map[string]any)
|
||||
if !ok || summary["force_replace"] != float64(1) || summary["publish_new"] != float64(0) {
|
||||
t.Fatalf("summary = %#v, want force_replace only", result["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFansOutToLocalDestinations(t *testing.T) {
|
||||
@@ -1492,7 +1597,7 @@ func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
|
||||
"pipeline=local-to-ssh source=local",
|
||||
"destination=ssh-archive backend=ssh action=publish_new",
|
||||
"pipeline=ssh-to-local source=ssh",
|
||||
"Final status: ok planned=4 publish_new=4 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true",
|
||||
"Final status: ok planned=4 publish_new=4 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=0 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=true",
|
||||
} {
|
||||
if !strings.Contains(dryRunOutput.String(), want) {
|
||||
t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want)
|
||||
@@ -1565,6 +1670,12 @@ func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
|
||||
testutil.AssertFakeFile(t, sshDestination, "bundle/report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeMissing(t, sshDestination, "bundle/old.txt")
|
||||
testutil.AssertFakeFile(t, sshDestination, "bundle-sibling/keep.txt", "keep")
|
||||
for name, backend := range map[string]*fake.Backend{"s3": s3Destination, "ssh": sshDestination} {
|
||||
catalog := readFakeCatalogStateAt(t, backend, "bundle")
|
||||
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
|
||||
t.Fatalf("%s catalog identity = schema %d mode %s", name, catalog.SchemaVersion, catalog.State.Mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunDoesNotWrite(t *testing.T) {
|
||||
@@ -1622,6 +1733,24 @@ func writeLocalConfig(t *testing.T, sourceRoot, destinationRoot string) string {
|
||||
return testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
}
|
||||
|
||||
func writeLocalConfigWithWorkflow(t *testing.T, sourceRoot, destinationRoot, pathMapping, workflow string) string {
|
||||
t.Helper()
|
||||
return writeConfigFile(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: `+sourceRoot+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: `+destinationRoot+`
|
||||
workflow: `+workflow+`
|
||||
path_mapping:
|
||||
mode: `+pathMapping+`
|
||||
`)
|
||||
}
|
||||
|
||||
func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestination string) string {
|
||||
t.Helper()
|
||||
return testutil.WriteFanoutLocalConfig(t, sourceRoot, firstDestination, secondDestination)
|
||||
@@ -1630,11 +1759,15 @@ func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestina
|
||||
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
|
||||
token_env: UPLOAD_TOKEN
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
@@ -1674,11 +1807,6 @@ func writeConfigFile(t *testing.T, body string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func writeDestinationState(t *testing.T, root, relative string, manifest bundle.Manifest) {
|
||||
t.Helper()
|
||||
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 {
|
||||
@@ -1693,13 +1821,93 @@ func writeJSONManifest(t *testing.T, root string, manifest bundle.Manifest) {
|
||||
}
|
||||
}
|
||||
|
||||
func readStateFile(t *testing.T, path string) state.DistributorState {
|
||||
t.Helper()
|
||||
return testutil.ReadDestinationState(t, path)
|
||||
type testDestinationState struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
SourceID string
|
||||
SourceDigest string
|
||||
SourceCreated time.Time
|
||||
PrimaryURL string
|
||||
Outputs []testStateOutput
|
||||
}
|
||||
|
||||
func outputsByPath(outputs []state.OutputFile) map[string]state.OutputFile {
|
||||
byPath := make(map[string]state.OutputFile, len(outputs))
|
||||
type testStateOutput struct {
|
||||
Path string
|
||||
Kind string
|
||||
SourcePath string
|
||||
Transform string
|
||||
URL string
|
||||
SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func readStateFile(t *testing.T, path string) testDestinationState {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read destination state: %v", err)
|
||||
}
|
||||
catalog, err := state.ParseCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse catalog state: %v", err)
|
||||
}
|
||||
view := testDestinationState{Outputs: make([]testStateOutput, 0, len(catalog.Outputs))}
|
||||
for index, output := range catalog.Outputs {
|
||||
if index == 0 {
|
||||
view.PipelineID = output.PipelineID
|
||||
view.DestinationID = output.DestinationID
|
||||
view.SourceID = output.Source.ID
|
||||
view.SourceDigest = output.Source.Digest
|
||||
view.SourceCreated = output.Source.Created
|
||||
if output.URL != "" {
|
||||
view.PrimaryURL = output.URL
|
||||
}
|
||||
}
|
||||
view.Outputs = append(view.Outputs, testStateOutput{
|
||||
Path: output.Path,
|
||||
Kind: output.Kind,
|
||||
SourcePath: output.SourcePath,
|
||||
Transform: output.Transform,
|
||||
URL: output.URL,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func readLocalCatalogState(t *testing.T, destinationRoot string) state.CatalogState {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("read catalog state: %v", err)
|
||||
}
|
||||
catalog, err := state.ParseCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse catalog state: %v", err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func readFakeCatalogStateAt(t *testing.T, backend *fake.Backend, bundlePath string) state.CatalogState {
|
||||
t.Helper()
|
||||
statePath, err := storage.StatePath(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatalf("state path: %v", err)
|
||||
}
|
||||
data, err := backend.ReadFile(context.Background(), statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read catalog state: %v", err)
|
||||
}
|
||||
catalog, err := state.ParseCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse catalog state: %v", err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func outputsByPath(outputs []testStateOutput) map[string]testStateOutput {
|
||||
byPath := make(map[string]testStateOutput, len(outputs))
|
||||
for _, output := range outputs {
|
||||
byPath[output.Path] = output
|
||||
}
|
||||
|
||||
44
internal/app/runtime.go
Normal file
44
internal/app/runtime.go
Normal 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
|
||||
}
|
||||
32
internal/app/runtime_test.go
Normal file
32
internal/app/runtime_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
)
|
||||
|
||||
type ServeOptions struct {
|
||||
@@ -22,26 +20,18 @@ func Serve(ctx context.Context, options ServeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := options.ConfigPath
|
||||
if configPath == "" {
|
||||
configPath = config.DefaultConfigPath
|
||||
}
|
||||
cfg, err := config.LoadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handler, err := newUploadHTTPHandler(ctx, cfg, secretLoad.Environment)
|
||||
handler, err := newUploadHTTPHandler(ctx, setup.Config, setup.Environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", cfg.Server.HTTP.Bind)
|
||||
listener, err := net.Listen("tcp", setup.Config.Server.HTTP.Bind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind HTTP server %q: %w", cfg.Server.HTTP.Bind, err)
|
||||
return fmt.Errorf("bind HTTP server %q: %w", setup.Config.Server.HTTP.Bind, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
|
||||
98
internal/app/serve_test.go
Normal file
98
internal/app/serve_test.go
Normal 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)
|
||||
}
|
||||
@@ -44,11 +44,11 @@ func selectSourceBundles(ctx context.Context, options sourceCommandOptions, prov
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
if options.ConfigPath != "" {
|
||||
cfg, err := config.LoadFile(options.ConfigPath)
|
||||
setup, err := loadRuntimeSetup(options.ConfigPath)
|
||||
if err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
return selectSourceBundlesFromConfig(ctx, cfg, options, provider)
|
||||
return selectSourceBundlesFromSetup(ctx, setup, options, provider)
|
||||
}
|
||||
if options.PipelineID != "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode requires --config")
|
||||
@@ -72,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) {
|
||||
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 != "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode does not accept a local path")
|
||||
}
|
||||
if options.PipelineID == "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode requires --pipeline")
|
||||
}
|
||||
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||
if err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
pipeline, ok := findPipeline(cfg, options.PipelineID)
|
||||
pipeline, ok := findPipeline(setup.Config, options.PipelineID)
|
||||
if !ok {
|
||||
return sourceSelection{}, PipelineNotFoundError{ID: options.PipelineID}
|
||||
}
|
||||
backends := provider(secretLoad.Environment)
|
||||
backends := provider(setup.Environment)
|
||||
sourceBackend, err := backends.openSource(ctx, pipeline.Source)
|
||||
if err != nil {
|
||||
return sourceSelection{}, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err)
|
||||
@@ -111,7 +115,7 @@ func selectSourceBundlesFromConfig(ctx context.Context, cfg config.Config, optio
|
||||
PipelineID: pipeline.ID,
|
||||
SourceBackend: pipeline.Source.Backend,
|
||||
ConfigMode: true,
|
||||
Warnings: append(secretConflictWarnings(secretLoad.Conflicts), sourceSSHWarnings(pipeline)...),
|
||||
Warnings: append(setup.Warnings, sourceSSHWarnings(pipeline)...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"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
|
||||
@@ -43,12 +44,14 @@ type UploadRunRecord struct {
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
PipelineID string
|
||||
ContentType string
|
||||
Body io.Reader
|
||||
DryRun bool
|
||||
Force bool
|
||||
MaxFileCount int
|
||||
TokenID string
|
||||
PipelineID string
|
||||
ContentType string
|
||||
Body io.Reader
|
||||
IdempotencyKey string
|
||||
DryRun bool
|
||||
Force bool
|
||||
MaxFileCount int
|
||||
}
|
||||
|
||||
type UploadQueueFullError struct {
|
||||
@@ -64,6 +67,22 @@ func IsUploadQueueFull(err error) bool {
|
||||
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
|
||||
@@ -78,9 +97,11 @@ type UploadCoordinator 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)
|
||||
@@ -88,9 +109,22 @@ type uploadStageFunc func(context.Context, ingest.StageOptions) (ingest.StagedBu
|
||||
type uploadRunFunc func(context.Context, config.Config, RunPipelineWithLocalSourceOptions) (RunReport, error)
|
||||
|
||||
type uploadJob struct {
|
||||
recordID UploadRunID
|
||||
request UploadRequest
|
||||
pipeline config.Pipeline
|
||||
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 {
|
||||
@@ -138,6 +172,7 @@ func newUploadCoordinator(ctx context.Context, cfg config.Config, hooks uploadCo
|
||||
maxConcurrency: cfg.Server.HTTP.MaxConcurrency,
|
||||
activePipeline: map[string]bool{},
|
||||
records: map[UploadRunID]UploadRunRecord{},
|
||||
idempotency: map[uploadIdempotencyScope]uploadIdempotencyRecord{},
|
||||
}
|
||||
go coordinator.dispatchLoop()
|
||||
return coordinator
|
||||
@@ -164,29 +199,102 @@ func (coordinator *UploadCoordinator) Submit(ctx context.Context, request Upload
|
||||
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()
|
||||
coordinator.expireLocked(coordinator.now().UTC())
|
||||
if len(coordinator.pending) >= coordinator.queueSize {
|
||||
return UploadRunRecord{}, UploadQueueFullError{QueueSize: coordinator.queueSize}
|
||||
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,
|
||||
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()
|
||||
@@ -205,13 +313,13 @@ func (coordinator *UploadCoordinator) CanAccept() bool {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
coordinator.expireLocked(coordinator.now().UTC())
|
||||
return len(coordinator.pending) < coordinator.queueSize
|
||||
return !coordinator.queueFullLocked()
|
||||
}
|
||||
|
||||
func (coordinator *UploadCoordinator) QueueDepth() int {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
return len(coordinator.pending)
|
||||
return len(coordinator.pending) + coordinator.reservedCount
|
||||
}
|
||||
|
||||
func (coordinator *UploadCoordinator) RunningCount() int {
|
||||
@@ -290,47 +398,35 @@ func (coordinator *UploadCoordinator) markPendingQueuedLocked() {
|
||||
}
|
||||
|
||||
func (coordinator *UploadCoordinator) runJob(job *uploadJob) {
|
||||
record := coordinator.currentRecord(job.recordID)
|
||||
maxFileCount := job.request.MaxFileCount
|
||||
if maxFileCount <= 0 {
|
||||
maxFileCount = DefaultUploadMaxFileCount
|
||||
}
|
||||
staged, err := coordinator.stage(coordinator.ctx, ingest.StageOptions{
|
||||
Body: job.request.Body,
|
||||
ContentType: job.request.ContentType,
|
||||
PipelineStagingPath: job.pipeline.Source.Upload.StagingPath,
|
||||
RunID: string(record.ID),
|
||||
MaxUploadSize: int64(*job.pipeline.Source.Upload.MaxUploadSize),
|
||||
MaxExtractedSize: int64(*job.pipeline.Source.Upload.MaxUploadSize),
|
||||
MaxFileCount: maxFileCount,
|
||||
report, err := coordinator.run(coordinator.ctx, coordinator.cfg, RunPipelineWithLocalSourceOptions{
|
||||
PipelineID: job.pipeline.ID,
|
||||
SourceRoot: job.stagedRoot,
|
||||
DryRun: job.request.DryRun,
|
||||
Force: job.request.Force,
|
||||
})
|
||||
if err == nil {
|
||||
coordinator.setStagedRoot(job.recordID, staged.Root)
|
||||
var report RunReport
|
||||
report, err = coordinator.run(coordinator.ctx, coordinator.cfg, RunPipelineWithLocalSourceOptions{
|
||||
PipelineID: job.pipeline.ID,
|
||||
SourceRoot: staged.Root,
|
||||
DryRun: job.request.DryRun,
|
||||
Force: job.request.Force,
|
||||
})
|
||||
coordinator.complete(job, &report, err)
|
||||
return
|
||||
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)
|
||||
}
|
||||
}
|
||||
coordinator.complete(job, nil, err)
|
||||
}
|
||||
|
||||
func (coordinator *UploadCoordinator) currentRecord(runID UploadRunID) UploadRunRecord {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
return coordinator.records[runID]
|
||||
func (coordinator *UploadCoordinator) queueFullLocked() bool {
|
||||
return len(coordinator.pending)+coordinator.reservedCount >= coordinator.queueSize
|
||||
}
|
||||
|
||||
func (coordinator *UploadCoordinator) setStagedRoot(runID UploadRunID, root string) {
|
||||
coordinator.mu.Lock()
|
||||
defer coordinator.mu.Unlock()
|
||||
record := coordinator.records[runID]
|
||||
record.StagedRoot = root
|
||||
coordinator.records[runID] = record
|
||||
func uploadMaxFileCount(value int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return DefaultUploadMaxFileCount
|
||||
}
|
||||
|
||||
func (coordinator *UploadCoordinator) complete(job *uploadJob, report *RunReport, runErr error) {
|
||||
@@ -369,10 +465,31 @@ func (coordinator *UploadCoordinator) expireLocked(now time.Time) []UploadRunRec
|
||||
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{}{}:
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"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) {
|
||||
@@ -254,6 +255,330 @@ func TestUploadCoordinatorExpiresCompletedRecordsAndStagingDirectories(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -268,6 +593,37 @@ func successfulUploadStage(ctx context.Context, opts ingest.StageOptions) (inges
|
||||
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
|
||||
}
|
||||
@@ -305,11 +661,11 @@ func uploadCoordinatorConfig(t *testing.T, opts uploadCoordinatorConfigOptions)
|
||||
}},
|
||||
}
|
||||
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,
|
||||
Upload: config.HTTPUpload{TokenEnv: strings.ToUpper(strings.ReplaceAll(pipelineID, "-", "_")) + "_TOKEN"},
|
||||
},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
@@ -317,6 +673,11 @@ func uploadCoordinatorConfig(t *testing.T, opts uploadCoordinatorConfigOptions)
|
||||
Path: t.TempDir(),
|
||||
}},
|
||||
})
|
||||
cfg.UploadTokens = append(cfg.UploadTokens, config.UploadToken{
|
||||
ID: pipelineID + "-reporter",
|
||||
TokenEnv: tokenEnv,
|
||||
AllowPipelines: []string{pipelineID},
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -22,9 +19,15 @@ type uploadCoordinator interface {
|
||||
}
|
||||
|
||||
type uploadHTTPHandler struct {
|
||||
coordinator uploadCoordinator
|
||||
tokens map[string]string
|
||||
limits map[string]int64
|
||||
coordinator uploadCoordinator
|
||||
tokens map[string]resolvedUploadToken
|
||||
uploadPipelines map[string]struct{}
|
||||
}
|
||||
|
||||
type resolvedUploadToken struct {
|
||||
ID string
|
||||
Value string
|
||||
AllowedPipelines map[string]struct{}
|
||||
}
|
||||
|
||||
type uploadAcceptedResponse struct {
|
||||
@@ -33,51 +36,70 @@ type uploadAcceptedResponse struct {
|
||||
}
|
||||
|
||||
type httpErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
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, limits, err := resolveUploadTokens(cfg, environment)
|
||||
tokens, err := resolveUploadTokens(cfg, environment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uploadHTTPHandler{
|
||||
coordinator: NewUploadCoordinator(ctx, cfg),
|
||||
tokens: tokens,
|
||||
limits: limits,
|
||||
coordinator: NewUploadCoordinator(ctx, cfg),
|
||||
tokens: tokens,
|
||||
uploadPipelines: uploadPipelineSet(cfg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveUploadTokens(cfg config.Config, environment config.Environment) (map[string]string, map[string]int64, error) {
|
||||
tokens := make(map[string]string)
|
||||
limits := make(map[string]int64)
|
||||
for _, pipeline := range cfg.Pipelines {
|
||||
if pipeline.Source.Backend != config.BackendHTTPUpload {
|
||||
continue
|
||||
}
|
||||
tokenName := pipeline.Source.Upload.TokenEnv
|
||||
token, ok := environment.Lookup(tokenName)
|
||||
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, nil, fmt.Errorf("upload token environment variable %s is not set", tokenName)
|
||||
return nil, fmt.Errorf("upload token %s environment variable %s is not set", uploadToken.ID, uploadToken.TokenEnv)
|
||||
}
|
||||
if token == "" {
|
||||
return nil, nil, fmt.Errorf("upload token environment variable %s is empty", tokenName)
|
||||
return nil, fmt.Errorf("upload token %s environment variable %s is empty", uploadToken.ID, uploadToken.TokenEnv)
|
||||
}
|
||||
if existing, exists := tokens[token]; exists {
|
||||
return nil, nil, fmt.Errorf("upload token environment variables for pipelines %s and %s resolve to the same value", existing, pipeline.ID)
|
||||
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),
|
||||
}
|
||||
tokens[token] = pipeline.ID
|
||||
limits[pipeline.ID] = int64(*pipeline.Source.Upload.MaxUploadSize)
|
||||
}
|
||||
return tokens, limits, nil
|
||||
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 && r.URL.Path == "/upload":
|
||||
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)
|
||||
@@ -95,33 +117,44 @@ func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Req
|
||||
writeHTTPError(w, http.StatusBadRequest, "pipeline id is not accepted")
|
||||
return
|
||||
}
|
||||
pipelineID, ok := handler.authenticate(r.Header.Get("Authorization"))
|
||||
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 !supportedUploadContentType(contentType) {
|
||||
if err := ingest.ValidateContentType(contentType); err != nil {
|
||||
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
|
||||
return
|
||||
}
|
||||
if !handler.coordinator.CanAccept() {
|
||||
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
|
||||
return
|
||||
}
|
||||
body, err := readUploadBody(r.Body, handler.limits[pipelineID])
|
||||
idempotencyKey, err := uploadIdempotencyKey(r.Header)
|
||||
if err != nil {
|
||||
if errors.Is(err, ingest.ErrUploadTooLarge) {
|
||||
writeHTTPError(w, http.StatusRequestEntityTooLarge, "upload exceeds maximum size")
|
||||
return
|
||||
}
|
||||
writeHTTPError(w, http.StatusBadRequest, "read upload body failed")
|
||||
writeHTTPError(w, http.StatusBadRequest, "invalid idempotency key")
|
||||
return
|
||||
}
|
||||
record, err := handler.coordinator.Submit(r.Context(), UploadRequest{
|
||||
PipelineID: pipelineID,
|
||||
ContentType: contentType,
|
||||
Body: bytes.NewReader(body),
|
||||
TokenID: token.ID,
|
||||
PipelineID: pipelineID,
|
||||
ContentType: contentType,
|
||||
Body: r.Body,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
writeUploadSubmitError(w, err)
|
||||
@@ -133,6 +166,19 @@ func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Req
|
||||
})
|
||||
}
|
||||
|
||||
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, "/") {
|
||||
@@ -147,48 +193,57 @@ func (handler uploadHTTPHandler) handleRunStatus(w http.ResponseWriter, r *http.
|
||||
writeJSON(w, http.StatusOK, record)
|
||||
}
|
||||
|
||||
func (handler uploadHTTPHandler) authenticate(header string) (string, bool) {
|
||||
func (handler uploadHTTPHandler) authenticate(header string) (resolvedUploadToken, bool) {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", false
|
||||
return resolvedUploadToken{}, false
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||
if token == "" {
|
||||
return "", false
|
||||
return resolvedUploadToken{}, false
|
||||
}
|
||||
pipelineID, ok := handler.tokens[token]
|
||||
return pipelineID, ok
|
||||
resolved, ok := handler.tokens[token]
|
||||
return resolved, ok
|
||||
}
|
||||
|
||||
func supportedUploadContentType(contentType string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = contentType
|
||||
func uploadIdempotencyKey(header http.Header) (string, error) {
|
||||
values := header.Values(idempotencyKeyHeader)
|
||||
if len(values) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
switch mediaType {
|
||||
case ingest.ContentTypeTar, ingest.ContentTypeGzip, ingest.ContentTypeXGzip:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
if len(values) != 1 {
|
||||
return "", fmt.Errorf("idempotency key must appear at most once")
|
||||
}
|
||||
}
|
||||
|
||||
func readUploadBody(body io.Reader, maxSize int64) ([]byte, error) {
|
||||
limited := &io.LimitedReader{R: body, N: maxSize + 1}
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
key := values[0]
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("idempotency key is required when header is present")
|
||||
}
|
||||
if int64(len(data)) > maxSize {
|
||||
return nil, ingest.ErrUploadTooLarge
|
||||
if len(key) > 128 {
|
||||
return "", fmt.Errorf("idempotency key must be at most 128 bytes")
|
||||
}
|
||||
return data, nil
|
||||
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):
|
||||
@@ -199,7 +254,11 @@ func writeUploadSubmitError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeHTTPError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, httpErrorResponse{Error: message})
|
||||
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) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"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) {
|
||||
@@ -68,32 +70,125 @@ func TestHTTPUploadPublishesTarAndGzipFanout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPUploadInvalidArchiveFailsWithoutPublishing(t *testing.T) {
|
||||
func TestHTTPUploadInvalidArchiveIsRejectedWithoutRunID(t *testing.T) {
|
||||
destination := t.TempDir()
|
||||
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
|
||||
coordinator := NewUploadCoordinator(context.Background(), httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
|
||||
id: "reports",
|
||||
tokenEnv: "REPORTS_TOKEN",
|
||||
stagingPath: filepath.Join(t.TempDir(), "reports"),
|
||||
destinations: []string{destination},
|
||||
}}, 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)
|
||||
}}, 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()
|
||||
|
||||
runID := submitHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("not a tar archive"))
|
||||
record := waitForHTTPUploadStatus(t, server, runID, UploadStatusFailed)
|
||||
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)
|
||||
}
|
||||
|
||||
if record.Error == "" {
|
||||
t.Fatal("failed status error is empty")
|
||||
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"}),
|
||||
}
|
||||
if record.Report != nil {
|
||||
t.Fatalf("failed staging report = %#v, want nil", record.Report)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -120,9 +215,9 @@ func TestHTTPUploadSamePipelineRequestsSerialize(t *testing.T) {
|
||||
},
|
||||
})
|
||||
handler := uploadHTTPHandler{
|
||||
coordinator: coordinator,
|
||||
tokens: map[string]string{"reports-secret": "reports"},
|
||||
limits: map[string]int64{"reports": 1024},
|
||||
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()
|
||||
@@ -173,20 +268,17 @@ func TestHTTPUploadDifferentPipelinesRunConcurrently(t *testing.T) {
|
||||
})
|
||||
handler := uploadHTTPHandler{
|
||||
coordinator: coordinator,
|
||||
tokens: map[string]string{
|
||||
"one-secret": "reports-one",
|
||||
"two-secret": "reports-two",
|
||||
},
|
||||
limits: map[string]int64{
|
||||
"reports-one": 1024,
|
||||
"reports-two": 1024,
|
||||
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 := submitHTTPUpload(t, server, "one-secret", ingest.ContentTypeTar, []byte("first"))
|
||||
secondRunID := submitHTTPUpload(t, server, "two-secret", ingest.ContentTypeTar, []byte("second"))
|
||||
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)
|
||||
@@ -199,6 +291,199 @@ func TestHTTPUploadDifferentPipelinesRunConcurrently(t *testing.T) {
|
||||
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
|
||||
@@ -226,7 +511,6 @@ func httpUploadIntegrationConfig(t *testing.T, pipelines []httpUploadPipelineSpe
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendHTTPUpload,
|
||||
Upload: config.HTTPUpload{
|
||||
TokenEnv: spec.tokenEnv,
|
||||
StagingPath: spec.stagingPath,
|
||||
MaxUploadSize: &size,
|
||||
},
|
||||
@@ -241,12 +525,87 @@ func httpUploadIntegrationConfig(t *testing.T, pipelines []httpUploadPipelineSpe
|
||||
})
|
||||
}
|
||||
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 {
|
||||
@@ -256,20 +615,14 @@ func submitHTTPUpload(t *testing.T, server *httptest.Server, token, contentType
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
response, err := server.Client().Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /upload error = %v", err)
|
||||
t.Fatalf("POST legacy upload error = %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("POST /upload status = %d, want %d", response.StatusCode, http.StatusAccepted)
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response body: %v", err)
|
||||
}
|
||||
var accepted uploadAcceptedResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&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
|
||||
return response.StatusCode, string(data)
|
||||
}
|
||||
|
||||
func waitForHTTPUploadStatus(t *testing.T, server *httptest.Server, runID UploadRunID, status UploadStatus) UploadRunRecord {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
|
||||
)
|
||||
|
||||
type fakeUploadCoordinator struct {
|
||||
@@ -41,7 +42,7 @@ func (fake fakeUploadCoordinator) Status(runID UploadRunID) (UploadRunRecord, bo
|
||||
func TestResolveUploadTokensFailsForMissingAndDuplicateTokens(t *testing.T) {
|
||||
cfg := uploadHTTPTestConfig()
|
||||
|
||||
_, _, err := resolveUploadTokens(cfg, config.NewEnvironment(nil, func(string) (string, bool) {
|
||||
_, err := resolveUploadTokens(cfg, config.NewEnvironment(nil, func(string) (string, bool) {
|
||||
return "", false
|
||||
}))
|
||||
if err == nil || !strings.Contains(err.Error(), "UPLOAD_TOKEN") {
|
||||
@@ -52,13 +53,17 @@ func TestResolveUploadTokensFailsForMissingAndDuplicateTokens(t *testing.T) {
|
||||
ID: "weekly",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendHTTPUpload,
|
||||
Upload: config.HTTPUpload{TokenEnv: "OTHER_UPLOAD_TOKEN"},
|
||||
},
|
||||
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{
|
||||
_, err = resolveUploadTokens(cfg, uploadHTTPTestEnvironment(map[string]string{
|
||||
"UPLOAD_TOKEN": secret,
|
||||
"OTHER_UPLOAD_TOKEN": secret,
|
||||
}))
|
||||
@@ -70,6 +75,39 @@ func TestResolveUploadTokensFailsForMissingAndDuplicateTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 = ""
|
||||
@@ -96,7 +134,6 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
|
||||
var submitted UploadRequest
|
||||
handler := uploadHTTPHandler{
|
||||
coordinator: fakeUploadCoordinator{
|
||||
canAccept: true,
|
||||
submit: func(_ context.Context, request UploadRequest) (UploadRunRecord, error) {
|
||||
submitted = request
|
||||
body, err := io.ReadAll(request.Body)
|
||||
@@ -109,13 +146,14 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
|
||||
return UploadRunRecord{ID: "reports.20260603T120000Z.abcdef12", Status: UploadStatusAccepted}, nil
|
||||
},
|
||||
},
|
||||
tokens: map[string]string{"valid-token": "reports"},
|
||||
limits: map[string]int64{"reports": 1024},
|
||||
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
|
||||
uploadPipelines: pipelineIDSet([]string{"reports"}),
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader("archive"))
|
||||
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)
|
||||
|
||||
@@ -125,6 +163,12 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
|
||||
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)
|
||||
@@ -139,14 +183,14 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
|
||||
|
||||
func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) {
|
||||
handler := uploadHTTPHandler{
|
||||
coordinator: fakeUploadCoordinator{canAccept: true},
|
||||
tokens: map[string]string{"valid-token": "reports"},
|
||||
limits: map[string]int64{"reports": 1024},
|
||||
coordinator: fakeUploadCoordinator{},
|
||||
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
|
||||
uploadPipelines: pipelineIDSet([]string{"reports"}),
|
||||
}
|
||||
|
||||
for _, authHeader := range []string{"", "Bearer wrong-token"} {
|
||||
for _, authHeader := range []string{"", "Basic valid-token", "Bearer", "Bearer wrong-token"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader("archive"))
|
||||
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")
|
||||
|
||||
@@ -161,44 +205,117 @@ func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadHTTPHandlerRejectsUnsupportedOversizedFullQueueAndPipelineID(t *testing.T) {
|
||||
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
|
||||
canAccept bool
|
||||
url string
|
||||
contentType string
|
||||
keyValues []string
|
||||
body io.Reader
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "unsupported content type",
|
||||
canAccept: true,
|
||||
url: "/upload",
|
||||
url: "/v1/pipelines/reports/upload",
|
||||
contentType: "application/zip",
|
||||
body: strings.NewReader("archive"),
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
name: "oversized",
|
||||
canAccept: true,
|
||||
url: "/upload",
|
||||
name: "invalid key syntax",
|
||||
url: "/v1/pipelines/reports/upload",
|
||||
contentType: "application/x-tar",
|
||||
body: strings.NewReader("too-large"),
|
||||
wantStatus: http.StatusRequestEntityTooLarge,
|
||||
keyValues: []string{"bad key"},
|
||||
body: strings.NewReader("archive"),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "full queue",
|
||||
canAccept: false,
|
||||
url: "/upload",
|
||||
name: "empty key",
|
||||
url: "/v1/pipelines/reports/upload",
|
||||
contentType: "application/x-tar",
|
||||
body: &countingReader{reader: strings.NewReader("archive")},
|
||||
wantStatus: http.StatusServiceUnavailable,
|
||||
keyValues: []string{""},
|
||||
body: strings.NewReader("archive"),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "submitted pipeline id",
|
||||
canAccept: true,
|
||||
url: "/upload?pipeline_id=reports",
|
||||
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,
|
||||
},
|
||||
@@ -207,27 +324,69 @@ func TestUploadHTTPHandlerRejectsUnsupportedOversizedFullQueueAndPipelineID(t *t
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := uploadHTTPHandler{
|
||||
coordinator: fakeUploadCoordinator{
|
||||
canAccept: tt.canAccept,
|
||||
submit: func(context.Context, UploadRequest) (UploadRunRecord, error) {
|
||||
t.Fatal("Submit should not be called")
|
||||
return UploadRunRecord{}, nil
|
||||
},
|
||||
},
|
||||
tokens: map[string]string{"valid-token": "reports"},
|
||||
limits: map[string]int64{"reports": 4},
|
||||
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())
|
||||
}
|
||||
if reader, ok := tt.body.(*countingReader); ok && reader.reads != 0 {
|
||||
t.Fatalf("full queue read body %d time(s), want zero", reader.reads)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -250,8 +409,8 @@ func TestUploadHTTPHandlerRunStatusAndHealth(t *testing.T) {
|
||||
}, true
|
||||
},
|
||||
},
|
||||
tokens: map[string]string{"valid-token": "reports"},
|
||||
limits: map[string]int64{"reports": 1024},
|
||||
tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
|
||||
uploadPipelines: pipelineIDSet([]string{"reports"}),
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -307,7 +466,6 @@ func uploadHTTPTestConfig() config.Config {
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendHTTPUpload,
|
||||
Upload: config.HTTPUpload{
|
||||
TokenEnv: "UPLOAD_TOKEN",
|
||||
StagingPath: "/tmp/distributor-test/reports",
|
||||
MaxUploadSize: &size,
|
||||
},
|
||||
@@ -319,6 +477,11 @@ func uploadHTTPTestConfig() config.Config {
|
||||
Publish: &config.PublishPolicy{Source: true},
|
||||
}},
|
||||
}},
|
||||
UploadTokens: []config.UploadToken{{
|
||||
ID: "reporter",
|
||||
TokenEnv: "UPLOAD_TOKEN",
|
||||
AllowPipelines: []string{"reports"},
|
||||
}},
|
||||
}
|
||||
config.ApplyDefaults(&cfg)
|
||||
return cfg
|
||||
@@ -329,3 +492,11 @@ func uploadHTTPTestEnvironment(values map[string]string) config.Environment {
|
||||
return "", false
|
||||
})
|
||||
}
|
||||
|
||||
func uploadHTTPTestToken(id, value string, pipelines ...string) resolvedUploadToken {
|
||||
return resolvedUploadToken{
|
||||
ID: id,
|
||||
Value: value,
|
||||
AllowedPipelines: pipelineIDSet(pipelines),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type ValidateOptions struct {
|
||||
@@ -73,29 +72,17 @@ func writeValidateResult(options ValidateOptions, selection sourceSelection) err
|
||||
}
|
||||
|
||||
type validateResult struct {
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
SourceBackend string `json:"source_backend,omitempty"`
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []validateBundleResult `json:"bundles"`
|
||||
}
|
||||
|
||||
type validateBundleResult struct {
|
||||
Path string `json:"path"`
|
||||
ID string `json:"id"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
SourceBackend string `json:"source_backend,omitempty"`
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []bundleSummaryResult `json:"bundles"`
|
||||
}
|
||||
|
||||
func validateResultFromSelection(selection sourceSelection) validateResult {
|
||||
result := validateResult{
|
||||
return validateResult{
|
||||
PipelineID: selection.PipelineID,
|
||||
SourceBackend: selection.SourceBackend,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
|
||||
@@ -70,11 +70,19 @@ func TestParseManifestRejectsInvalidDigestFormat(t *testing.T) {
|
||||
|
||||
func TestParseManifestRejectsUnsafeFilePaths(t *testing.T) {
|
||||
tests := []string{
|
||||
`"path": ""`,
|
||||
`"path": "."`,
|
||||
`"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": "manifest.json"`,
|
||||
`"path": "nested/manifest.json"`,
|
||||
`"path": "` + storage.StateFileName + `"`,
|
||||
`"path": "nested/` + storage.StateFileName + `"`,
|
||||
}
|
||||
for _, replacement := range tests {
|
||||
t.Run(replacement, func(t *testing.T) {
|
||||
@@ -128,6 +136,16 @@ func TestValidateManifestRejectsInvalidManifest(t *testing.T) {
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
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 {
|
||||
manifest.Files[1].Path = manifest.Files[0].Path
|
||||
manifest.Digest = BundleDigest(manifest.Files)
|
||||
|
||||
@@ -65,6 +65,38 @@ func TestValidateRejectsSymlinkFile(t *testing.T) {
|
||||
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 {
|
||||
t.Helper()
|
||||
backend := fake.New()
|
||||
|
||||
12
internal/cli/flags.go
Normal file
12
internal/cli/flags.go
Normal 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
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -30,8 +29,7 @@ func manifestCreateCommand(ctx context.Context, args []string, stdout, stderr io
|
||||
printManifestCreateHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
flags := flag.NewFlagSet("manifest create", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
flags := newFlagSet("manifest create", stderr)
|
||||
id := flags.String("id", "", "source bundle id")
|
||||
created := flags.String("created", "", "source created timestamp")
|
||||
overwrite := flags.Bool("overwrite", false, "replace an existing manifest.json")
|
||||
|
||||
88
internal/cli/prune.go
Normal file
88
internal/cli/prune.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
func pruneCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if hasHelp(args) {
|
||||
printPruneHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
flags := newFlagSet("prune", stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
pipelineID := flags.String("pipeline", "", "pipeline id")
|
||||
destinationID := flags.String("destination", "", "destination id")
|
||||
dryRun := flags.Bool("dry-run", false, "report planned deletes without deleting outputs or rewriting state")
|
||||
apply := flags.Bool("apply", false, "delete planned managed outputs and rewrite state")
|
||||
formatFlag := addFormatFlag(flags)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
if rejectPositionalArgs(stderr, "prune", flags.Args()) {
|
||||
return exitUsage
|
||||
}
|
||||
format, ok := parseOutputFormat(stderr, "prune", *formatFlag)
|
||||
if !ok {
|
||||
return exitUsage
|
||||
}
|
||||
if !validatePruneFlags(stderr, *configPath, *pipelineID, *destinationID, *dryRun, *apply) {
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if _, err := app.Prune(ctx, app.PruneOptions{
|
||||
ConfigPath: *configPath,
|
||||
PipelineID: *pipelineID,
|
||||
DestinationID: *destinationID,
|
||||
DryRun: *dryRun,
|
||||
Stdout: stdout,
|
||||
OutputFormat: format,
|
||||
}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func validatePruneFlags(stderr io.Writer, configPath, pipelineID, destinationID string, dryRun, apply bool) bool {
|
||||
if configPath == "" {
|
||||
fmt.Fprintf(stderr, "%s: prune requires --config\n", app.Name)
|
||||
return false
|
||||
}
|
||||
if pipelineID == "" {
|
||||
fmt.Fprintf(stderr, "%s: prune requires --pipeline\n", app.Name)
|
||||
return false
|
||||
}
|
||||
if destinationID == "" {
|
||||
fmt.Fprintf(stderr, "%s: prune requires --destination\n", app.Name)
|
||||
return false
|
||||
}
|
||||
if dryRun == apply {
|
||||
fmt.Fprintf(stderr, "%s: prune requires exactly one of --dry-run or --apply\n", app.Name)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func printPruneHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor prune --config <path> --pipeline <id> --destination <id> (--dry-run|--apply) [--format text|json]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config file
|
||||
--pipeline <id> Pipeline id that selects the destination root
|
||||
--destination <id> Destination id that selects the destination root
|
||||
--dry-run Report planned deletes without deleting outputs or rewriting state
|
||||
--apply Delete planned managed outputs and rewrite state
|
||||
--format text|json Output format
|
||||
|
||||
Prune reads the selected destination's configured retention policy and managed
|
||||
state, then plans owner-scoped managed output deletion. --dry-run is read-only.
|
||||
--apply deletes only planned managed output paths, preserves unmanaged files,
|
||||
and rewrites state after confirmed deletes.
|
||||
`)
|
||||
}
|
||||
286
internal/cli/prune_test.go
Normal file
286
internal/cli/prune_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestExecutePruneRejectsInvalidFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "missing config",
|
||||
args: []string{"prune", "--pipeline", "reports", "--destination", "archive", "--dry-run"},
|
||||
wantStderr: "requires --config",
|
||||
},
|
||||
{
|
||||
name: "missing pipeline",
|
||||
args: []string{"prune", "--config", "config.yml", "--destination", "archive", "--dry-run"},
|
||||
wantStderr: "requires --pipeline",
|
||||
},
|
||||
{
|
||||
name: "missing destination",
|
||||
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--dry-run"},
|
||||
wantStderr: "requires --destination",
|
||||
},
|
||||
{
|
||||
name: "missing mode",
|
||||
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive"},
|
||||
wantStderr: "requires exactly one of --dry-run or --apply",
|
||||
},
|
||||
{
|
||||
name: "conflicting modes",
|
||||
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "--apply"},
|
||||
wantStderr: "requires exactly one of --dry-run or --apply",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "--format", "xml"},
|
||||
wantStderr: "format must be text or json",
|
||||
},
|
||||
{
|
||||
name: "positional",
|
||||
args: []string{"prune", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--dry-run", "extra"},
|
||||
wantStderr: "does not accept positional arguments",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(context.Background(), tt.args, &stdout, &stderr)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitUsage, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tt.wantStderr) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePruneDryRunReportsWithoutWriting(t *testing.T) {
|
||||
destinationRoot, configPath := writePruneLocalFixture(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{
|
||||
"prune",
|
||||
"--config", configPath,
|
||||
"--pipeline", "reports",
|
||||
"--destination", "archive",
|
||||
"--dry-run",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if output := stdout.String(); !strings.Contains(output, "status=would_change") || !strings.Contains(output, "planned=2") || !strings.Contains(output, "deleted=0") {
|
||||
t.Fatalf("stdout = %q, want dry-run prune summary", output)
|
||||
}
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n")
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "html.txt"), "other")
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged")
|
||||
catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,summary.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want original outputs", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePruneJSONReport(t *testing.T) {
|
||||
_, configPath := writePruneLocalFixture(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{
|
||||
"prune",
|
||||
"--config", configPath,
|
||||
"--pipeline", "reports",
|
||||
"--destination", "archive",
|
||||
"--dry-run",
|
||||
"--format", "json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
envelope := decodeEnvelope(t, &stdout)
|
||||
if envelope["command"] != "prune" || envelope["ok"] != true {
|
||||
t.Fatalf("envelope = %#v, want prune ok", envelope)
|
||||
}
|
||||
result := envelopeResult(t, envelope)
|
||||
if result["would_change"] != true || result["state_changed"] != false || result["dry_run"] != true {
|
||||
t.Fatalf("result = %#v, want dry-run pending change", result)
|
||||
}
|
||||
planned, ok := result["planned_outputs"].([]any)
|
||||
if !ok || len(planned) != 2 {
|
||||
t.Fatalf("planned outputs = %#v, want two", result["planned_outputs"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePruneApplyDeletesManagedOutputs(t *testing.T) {
|
||||
destinationRoot, configPath := writePruneLocalFixture(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{
|
||||
"prune",
|
||||
"--config", configPath,
|
||||
"--pipeline", "reports",
|
||||
"--destination", "archive",
|
||||
"--apply",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if output := stdout.String(); !strings.Contains(output, "status=changed") || !strings.Contains(output, "deleted=2") {
|
||||
t.Fatalf("stdout = %q, want applied prune summary", output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "report.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("report.md stat error = %v, want not exist", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "summary.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("summary.txt stat error = %v, want not exist", err)
|
||||
}
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "html.txt"), "other")
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged")
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil {
|
||||
t.Fatalf("state file stat error = %v", err)
|
||||
}
|
||||
catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "html.txt" {
|
||||
t.Fatalf("state outputs = %q, want html.txt", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func writePruneLocalFixture(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
writeCatalogDestinationState(t, destinationRoot, manifest, true)
|
||||
for _, file := range testutil.DefaultSourceFiles() {
|
||||
path := filepath.Join(destinationRoot, filepath.FromSlash(file.Path))
|
||||
if err := os.WriteFile(path, []byte(file.Data), 0o600); err != nil {
|
||||
t.Fatalf("write destination output: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "html.txt"), []byte("other"), 0o600); err != nil {
|
||||
t.Fatalf("write other owner output: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged output: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
config := `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: ` + sourceRoot + `
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: ` + destinationRoot + `
|
||||
retention:
|
||||
prune:
|
||||
enabled: true
|
||||
older_than: 1h
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(strings.TrimSpace(config)+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write prune config: %v", err)
|
||||
}
|
||||
return destinationRoot, configPath
|
||||
}
|
||||
|
||||
func writeCatalogDestinationState(t *testing.T, root string, manifest bundle.Manifest, includeOtherOwner bool) {
|
||||
t.Helper()
|
||||
createdAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
source := state.CatalogSourceIdentity{ID: manifest.ID, Digest: manifest.Digest, Created: manifest.Created}
|
||||
outputs := []state.CatalogOutputFile{{
|
||||
Path: "report.md",
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Source: source,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: manifest.Files[0].SHA256,
|
||||
Size: manifest.Files[0].Size,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}, {
|
||||
Path: "summary.txt",
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
Source: source,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: manifest.Files[1].SHA256,
|
||||
Size: manifest.Files[1].Size,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}}
|
||||
if includeOtherOwner {
|
||||
outputs = append(outputs, state.CatalogOutputFile{
|
||||
Path: "html.txt",
|
||||
PipelineID: "reports",
|
||||
DestinationID: "html",
|
||||
Source: source,
|
||||
Kind: state.OutputKindSource,
|
||||
SHA256: manifest.Files[0].SHA256,
|
||||
Size: manifest.Files[0].Size,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
catalog := state.CatalogState{
|
||||
SchemaVersion: state.CatalogSchemaVersion,
|
||||
DistributorVersion: "test",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
State: state.StatePolicy{Mode: state.StateModeCatalog},
|
||||
Outputs: outputs,
|
||||
}
|
||||
data, err := json.MarshalIndent(catalog, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal catalog state: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, storage.StateFileName), append(data, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write catalog state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readLocalCatalogState(t *testing.T, path string) state.CatalogState {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read catalog state: %v", err)
|
||||
}
|
||||
catalog, err := state.ParseCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse catalog state: %v", err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
84
internal/cli/reconcile_state.go
Normal file
84
internal/cli/reconcile_state.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||
)
|
||||
|
||||
func reconcileStateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if hasHelp(args) {
|
||||
printReconcileStateHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
flags := newFlagSet("reconcile-state", stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
pipelineID := flags.String("pipeline", "", "pipeline id")
|
||||
destinationID := flags.String("destination", "", "destination id")
|
||||
allOwners := flags.Bool("all-owners", false, "repair all shared-root owners in the selected destination root")
|
||||
dryRun := flags.Bool("dry-run", false, "report repairs without rewriting state")
|
||||
formatFlag := addFormatFlag(flags)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
if rejectPositionalArgs(stderr, "reconcile-state", flags.Args()) {
|
||||
return exitUsage
|
||||
}
|
||||
format, ok := parseOutputFormat(stderr, "reconcile-state", *formatFlag)
|
||||
if !ok {
|
||||
return exitUsage
|
||||
}
|
||||
if !validateReconcileStateFlags(stderr, *configPath, *pipelineID, *destinationID) {
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if _, err := app.ReconcileState(ctx, app.ReconcileStateOptions{
|
||||
ConfigPath: *configPath,
|
||||
PipelineID: *pipelineID,
|
||||
DestinationID: *destinationID,
|
||||
AllOwners: *allOwners,
|
||||
DryRun: *dryRun,
|
||||
Stdout: stdout,
|
||||
OutputFormat: format,
|
||||
}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func validateReconcileStateFlags(stderr io.Writer, configPath, pipelineID, destinationID string) bool {
|
||||
if configPath == "" {
|
||||
fmt.Fprintf(stderr, "%s: reconcile-state requires --config\n", app.Name)
|
||||
return false
|
||||
}
|
||||
if pipelineID == "" {
|
||||
fmt.Fprintf(stderr, "%s: reconcile-state requires --pipeline\n", app.Name)
|
||||
return false
|
||||
}
|
||||
if destinationID == "" {
|
||||
fmt.Fprintf(stderr, "%s: reconcile-state requires --destination\n", app.Name)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func printReconcileStateHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor reconcile-state --config <path> --pipeline <id> --destination <id> [--all-owners] [--dry-run] [--format text|json]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config file
|
||||
--pipeline <id> Pipeline id that selects the destination root
|
||||
--destination <id> Destination id that selects the destination root
|
||||
--all-owners Repair all shared-root owners in the selected destination root
|
||||
--dry-run Report repairs without rewriting state
|
||||
--format text|json Output format
|
||||
|
||||
Reconcile-state checks managed output records against destination storage and
|
||||
removes records for missing managed outputs unless --dry-run is set. It reports
|
||||
unmanaged entries but does not delete or adopt destination files.
|
||||
`)
|
||||
}
|
||||
193
internal/cli/reconcile_state_test.go
Normal file
193
internal/cli/reconcile_state_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestExecuteReconcileStateAppliesByDefault(t *testing.T) {
|
||||
_, destinationRoot, configPath := writeReconcileStateLocalFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil {
|
||||
t.Fatalf("write managed output: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged output: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{
|
||||
"reconcile-state",
|
||||
"--config", configPath,
|
||||
"--pipeline", "reports",
|
||||
"--destination", "archive",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "status=changed") {
|
||||
t.Fatalf("stdout = %q, want changed status", stdout.String())
|
||||
}
|
||||
catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want report.md,html.txt", got)
|
||||
}
|
||||
assertLocalFile(t, filepath.Join(destinationRoot, "extra.txt"), "unmanaged")
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReconcileStateDryRunReportsWithoutWriting(t *testing.T) {
|
||||
_, destinationRoot, configPath := writeReconcileStateLocalFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil {
|
||||
t.Fatalf("write managed output: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{
|
||||
"reconcile-state",
|
||||
"--config", configPath,
|
||||
"--pipeline", "reports",
|
||||
"--destination", "archive",
|
||||
"--dry-run",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "status=would_change") {
|
||||
t.Fatalf("stdout = %q, want would_change status", stdout.String())
|
||||
}
|
||||
catalog := readLocalCatalogState(t, filepath.Join(destinationRoot, storage.StateFileName))
|
||||
if got := strings.Join(state.CatalogManagedOutputPaths(catalog), ","); got != "report.md,summary.txt,html.txt" {
|
||||
t.Fatalf("state outputs = %q, want original outputs", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReconcileStateJSONReport(t *testing.T) {
|
||||
_, destinationRoot, configPath := writeReconcileStateLocalFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("# Report\nSunny.\n"), 0o600); err != nil {
|
||||
t.Fatalf("write managed output: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "extra.txt"), []byte("unmanaged"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged output: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{
|
||||
"reconcile-state",
|
||||
"--config", configPath,
|
||||
"--pipeline", "reports",
|
||||
"--destination", "archive",
|
||||
"--dry-run",
|
||||
"--format", "json",
|
||||
}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
envelope := decodeEnvelope(t, &stdout)
|
||||
if envelope["command"] != "reconcile-state" || envelope["ok"] != true {
|
||||
t.Fatalf("envelope = %#v, want reconcile-state ok", envelope)
|
||||
}
|
||||
result := envelopeResult(t, envelope)
|
||||
if result["would_change"] != true || result["changed"] != false || result["dry_run"] != true {
|
||||
t.Fatalf("result = %#v, want dry-run pending change", result)
|
||||
}
|
||||
missing, ok := result["missing_managed_outputs"].([]any)
|
||||
if !ok || len(missing) != 1 {
|
||||
t.Fatalf("missing outputs = %#v, want one", result["missing_managed_outputs"])
|
||||
}
|
||||
unmanaged, ok := result["unmanaged_entries"].([]any)
|
||||
if !ok || len(unmanaged) != 1 {
|
||||
t.Fatalf("unmanaged entries = %#v, want one", result["unmanaged_entries"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReconcileStateRejectsInvalidFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "missing config",
|
||||
args: []string{"reconcile-state", "--pipeline", "reports", "--destination", "archive"},
|
||||
wantStderr: "requires --config",
|
||||
},
|
||||
{
|
||||
name: "missing pipeline",
|
||||
args: []string{"reconcile-state", "--config", "config.yml", "--destination", "archive"},
|
||||
wantStderr: "requires --pipeline",
|
||||
},
|
||||
{
|
||||
name: "missing destination",
|
||||
args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports"},
|
||||
wantStderr: "requires --destination",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "--format", "xml"},
|
||||
wantStderr: "format must be text or json",
|
||||
},
|
||||
{
|
||||
name: "positional",
|
||||
args: []string{"reconcile-state", "--config", "config.yml", "--pipeline", "reports", "--destination", "archive", "extra"},
|
||||
wantStderr: "does not accept positional arguments",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(context.Background(), tt.args, &stdout, &stderr)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitUsage, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tt.wantStderr) {
|
||||
t.Fatalf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeReconcileStateLocalFixture(t *testing.T) (string, string, string) {
|
||||
t.Helper()
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
manifest := testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
writeCatalogDestinationState(t, destinationRoot, manifest, true)
|
||||
if err := os.WriteFile(filepath.Join(destinationRoot, "html.txt"), []byte("other"), 0o600); err != nil {
|
||||
t.Fatalf("write other owner output: %v", err)
|
||||
}
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
return sourceRoot, destinationRoot, configPath
|
||||
}
|
||||
|
||||
func assertLocalFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file %s: %v", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("file %s = %q, want %q", path, data, want)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return versionCommand(ctx, args[1:], stdout, stderr)
|
||||
case "run":
|
||||
return runCommand(ctx, args[1:], stdout, stderr)
|
||||
case "reconcile-state":
|
||||
return reconcileStateCommand(ctx, args[1:], stdout, stderr)
|
||||
case "prune":
|
||||
return pruneCommand(ctx, args[1:], stdout, stderr)
|
||||
case "serve":
|
||||
return serveCommand(ctx, args[1:], stdout, stderr)
|
||||
case "validate":
|
||||
@@ -53,6 +57,9 @@ Usage:
|
||||
Commands:
|
||||
version Print version information
|
||||
run Run configured distribution pipelines
|
||||
reconcile-state
|
||||
Repair missing managed-output records in destination state
|
||||
prune Prune managed outputs using configured retention policy
|
||||
serve Run the HTTP upload server
|
||||
validate Validate a source bundle or bundle tree
|
||||
inspect Inspect bundles or distributor state
|
||||
|
||||
@@ -634,8 +634,8 @@ func TestExecuteRunDryRun(t *testing.T) {
|
||||
}
|
||||
wantStdout := "Configured pipelines: 1\n" +
|
||||
"- 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"
|
||||
" - bundle=. destination=archive backend=local action=publish_new workflow=additive outputs=report.md,summary.txt reason=\"\"\n" +
|
||||
"Final status: ok planned=1 publish_new=1 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=0 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=true fixed_path=0\n"
|
||||
if got := stdout.String(); got != wantStdout {
|
||||
t.Fatalf("stdout = %q, want %q", got, wantStdout)
|
||||
}
|
||||
@@ -693,6 +693,42 @@ func TestExecuteRunJSONDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunJSONReportsSkipSame(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
|
||||
var firstStdout, firstStderr bytes.Buffer
|
||||
if code := Execute(context.Background(), []string{"run", "--config", configPath}, &firstStdout, &firstStderr); code != exitOK {
|
||||
t.Fatalf("first exit code = %d, want %d; stderr = %q", code, exitOK, firstStderr.String())
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(context.Background(), []string{"run", "--config", configPath, "--format", "json"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
envelope := decodeEnvelope(t, &stdout)
|
||||
result := envelopeResult(t, envelope)
|
||||
actions, ok := result["actions"].([]any)
|
||||
if !ok || len(actions) != 1 {
|
||||
t.Fatalf("actions = %#v, want one action", result["actions"])
|
||||
}
|
||||
action, ok := actions[0].(map[string]any)
|
||||
if !ok || action["action"] != "skip_same" {
|
||||
t.Fatalf("action = %#v, want skip_same", actions[0])
|
||||
}
|
||||
summary, ok := result["summary"].(map[string]any)
|
||||
if !ok || summary["skip_same"] != float64(1) || summary["publish_new"] != float64(0) {
|
||||
t.Fatalf("summary = %#v, want skip_same counter", result["summary"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunJSONDryRunReportsFixedPathMapping(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
@@ -871,8 +907,8 @@ func TestExecuteRunJSONPartialFailure(t *testing.T) {
|
||||
firstDestination := t.TempDir()
|
||||
secondDestination := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged file: %v", err)
|
||||
if err := os.WriteFile(filepath.Join(firstDestination, "report.md"), []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write unmanaged planned file: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte(`
|
||||
@@ -938,8 +974,8 @@ func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "action=force_replace") {
|
||||
t.Fatalf("stdout = %q, want force_replace", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "action=force_replace workflow=additive") || !strings.Contains(stdout.String(), "force_replace=1") {
|
||||
t.Fatalf("stdout = %q, want forced replacement", stdout.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); err != nil {
|
||||
t.Fatalf("unmanaged file stat error = %v", err)
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -15,8 +14,7 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in
|
||||
return exitOK
|
||||
}
|
||||
|
||||
flags := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
flags := newFlagSet("run", stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
dryRun := flags.Bool("dry-run", false, "load and validate config without publishing")
|
||||
force := flags.Bool("force", false, "allow explicit destructive replacement for supported conflicts")
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -17,8 +16,7 @@ func serveCommand(ctx context.Context, args []string, stdout, stderr io.Writer)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
flags := newFlagSet("serve", stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -17,8 +16,7 @@ type sourceDiagnosticArgs struct {
|
||||
}
|
||||
|
||||
func parseSourceDiagnosticArgs(stderr io.Writer, command string, args []string) (sourceDiagnosticArgs, bool) {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
flags := newFlagSet(command, stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
pipelineID := flags.String("pipeline", "", "pipeline id")
|
||||
bundlePath := flags.String("bundle", "", "source-root-relative bundle path")
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -14,8 +13,7 @@ func versionCommand(_ context.Context, args []string, stdout, stderr io.Writer)
|
||||
printVersionHelp(stdout)
|
||||
return exitOK
|
||||
}
|
||||
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
flags := newFlagSet("version", stderr)
|
||||
formatFlag := addFormatFlag(flags)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
|
||||
50
internal/config/backend_view.go
Normal file
50
internal/config/backend_view.go
Normal 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,
|
||||
}
|
||||
}
|
||||
190
internal/config/backend_view_test.go
Normal file
190
internal/config/backend_view_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Secrets Secrets `yaml:"secrets"`
|
||||
Pipelines []Pipeline `yaml:"pipelines"`
|
||||
Server Server `yaml:"server"`
|
||||
Secrets Secrets `yaml:"secrets"`
|
||||
UploadTokens []UploadToken `yaml:"upload_tokens"`
|
||||
Pipelines []Pipeline `yaml:"pipelines"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -23,6 +24,12 @@ type Secrets struct {
|
||||
Directory string `yaml:"directory"`
|
||||
}
|
||||
|
||||
type UploadToken struct {
|
||||
ID string `yaml:"id"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
AllowPipelines []string `yaml:"allow_pipelines"`
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
ID string `yaml:"id"`
|
||||
Source Backend `yaml:"source"`
|
||||
@@ -31,24 +38,25 @@ type Pipeline struct {
|
||||
}
|
||||
|
||||
type Destination struct {
|
||||
ID string `yaml:"id"`
|
||||
Backend string `yaml:"backend"`
|
||||
Host string `yaml:"host"`
|
||||
User string `yaml:"user"`
|
||||
Port int `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Prefix string `yaml:"prefix"`
|
||||
Region string `yaml:"region"`
|
||||
ForcePath *bool `yaml:"force_path_style"`
|
||||
Creds Credentials `yaml:"credentials"`
|
||||
SSH SSH `yaml:",inline"`
|
||||
Publish *PublishPolicy `yaml:"publish"`
|
||||
Transform Transform `yaml:"transform"`
|
||||
PathMap PathMapping `yaml:"path_mapping"`
|
||||
Links *Links `yaml:"links"`
|
||||
Transfer TransferPolicy `yaml:"transfer"`
|
||||
ID string `yaml:"id"`
|
||||
Backend string `yaml:"backend"`
|
||||
Host string `yaml:"host"`
|
||||
User string `yaml:"user"`
|
||||
Port int `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Prefix string `yaml:"prefix"`
|
||||
Region string `yaml:"region"`
|
||||
ForcePath *bool `yaml:"force_path_style"`
|
||||
Creds Credentials `yaml:"credentials"`
|
||||
SSH SSH `yaml:",inline"`
|
||||
Publish *PublishPolicy `yaml:"publish"`
|
||||
Transform Transform `yaml:"transform"`
|
||||
PathMap PathMapping `yaml:"path_mapping"`
|
||||
Links *Links `yaml:"links"`
|
||||
Workflow string `yaml:"workflow"`
|
||||
Retention RetentionPolicy `yaml:"retention"`
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
@@ -68,7 +76,6 @@ type Backend struct {
|
||||
}
|
||||
|
||||
type HTTPUpload struct {
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
StagingPath string `yaml:"staging_path"`
|
||||
MaxUploadSize *ByteSize `yaml:"max_upload_size"`
|
||||
}
|
||||
@@ -101,6 +108,7 @@ type MarkdownToHTML struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Mode string `yaml:"mode"`
|
||||
Input string `yaml:"input"`
|
||||
CssHref string `yaml:"css_href"`
|
||||
}
|
||||
|
||||
type PathMapping struct {
|
||||
@@ -112,9 +120,12 @@ type Links struct {
|
||||
Primary string `yaml:"primary"`
|
||||
}
|
||||
|
||||
type TransferPolicy struct {
|
||||
OnDestinationSame string `yaml:"on_destination_same"`
|
||||
OnDestinationOlder string `yaml:"on_destination_older"`
|
||||
OnDestinationNewer string `yaml:"on_destination_newer"`
|
||||
OnConflict string `yaml:"on_conflict"`
|
||||
type RetentionPolicy struct {
|
||||
Prune PrunePolicy `yaml:"prune"`
|
||||
}
|
||||
|
||||
type PrunePolicy struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
OlderThan *Duration `yaml:"older_than"`
|
||||
KeepLatest *int `yaml:"keep_latest"`
|
||||
}
|
||||
|
||||
@@ -20,12 +20,6 @@ const (
|
||||
ValidationActionFail = "fail"
|
||||
)
|
||||
|
||||
const (
|
||||
TransferActionSkip = "skip"
|
||||
TransferActionReplace = "replace"
|
||||
TransferActionFail = "fail"
|
||||
)
|
||||
|
||||
const (
|
||||
TransformModeSidecar = transform.MarkdownModeSidecar
|
||||
TransformModeIndex = transform.MarkdownModeIndex
|
||||
@@ -42,6 +36,11 @@ const (
|
||||
LinkPrimarySource = "source"
|
||||
)
|
||||
|
||||
const (
|
||||
WorkflowAdditive = "additive"
|
||||
WorkflowReplacement = "replacement"
|
||||
)
|
||||
|
||||
const DefaultS3Region = "us-east-1"
|
||||
|
||||
const (
|
||||
@@ -79,17 +78,8 @@ func ApplyDefaults(cfg *Config) {
|
||||
if destination.Links != nil && destination.Links.Primary == "" {
|
||||
destination.Links.Primary = LinkPrimaryAuto
|
||||
}
|
||||
if destination.Transfer.OnDestinationSame == "" {
|
||||
destination.Transfer.OnDestinationSame = TransferActionSkip
|
||||
}
|
||||
if destination.Transfer.OnDestinationOlder == "" {
|
||||
destination.Transfer.OnDestinationOlder = TransferActionReplace
|
||||
}
|
||||
if destination.Transfer.OnDestinationNewer == "" {
|
||||
destination.Transfer.OnDestinationNewer = TransferActionSkip
|
||||
}
|
||||
if destination.Transfer.OnConflict == "" {
|
||||
destination.Transfer.OnConflict = TransferActionFail
|
||||
if destination.Workflow == "" {
|
||||
destination.Workflow = WorkflowAdditive
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,30 +124,24 @@ func duration(value Duration) *Duration {
|
||||
}
|
||||
|
||||
func applyBackendDefaults(backend *Backend) {
|
||||
if backend.Backend == BackendSSH {
|
||||
if backend.Port == 0 {
|
||||
backend.Port = 22
|
||||
}
|
||||
if backend.SSH.HostKeyPolicy == "" {
|
||||
backend.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
|
||||
}
|
||||
}
|
||||
if backend.Backend == BackendS3 {
|
||||
applyS3Defaults(&backend.Region, &backend.Prefix, &backend.ForcePath)
|
||||
}
|
||||
applyStorageBackendDefaults(backend.Backend, &backend.Port, &backend.SSH, &backend.Region, &backend.Prefix, &backend.ForcePath)
|
||||
}
|
||||
|
||||
func applyDestinationDefaults(destination *Destination) {
|
||||
if destination.Backend == BackendSSH {
|
||||
if destination.Port == 0 {
|
||||
destination.Port = 22
|
||||
applyStorageBackendDefaults(destination.Backend, &destination.Port, &destination.SSH, &destination.Region, &destination.Prefix, &destination.ForcePath)
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
destination.SSH.HostKeyPolicy = HostKeyPolicyAcceptNew
|
||||
if ssh.HostKeyPolicy == "" {
|
||||
ssh.HostKeyPolicy = HostKeyPolicyAcceptNew
|
||||
}
|
||||
}
|
||||
if destination.Backend == BackendS3 {
|
||||
applyS3Defaults(&destination.Region, &destination.Prefix, &destination.ForcePath)
|
||||
if backend == BackendS3 {
|
||||
applyS3Defaults(region, prefix, forcePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,11 @@ pipelines:
|
||||
if got, want := cfg.Pipelines[0].Validation.OnDigestMismatch, ValidationActionFail; got != want {
|
||||
t.Fatalf("validation default = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := destination.Transfer.OnDestinationOlder, TransferActionReplace; got != want {
|
||||
t.Fatalf("transfer default = %q, want %q", got, want)
|
||||
if got, want := destination.Workflow, WorkflowAdditive; got != want {
|
||||
t.Fatalf("workflow default = %q, want %q", got, want)
|
||||
}
|
||||
if destination.Retention.Prune.Enabled {
|
||||
t.Fatal("retention.prune.enabled default = true, want false")
|
||||
}
|
||||
if cfg.Secrets.Directory != "" {
|
||||
t.Fatalf("secrets.directory = %q, want empty", cfg.Secrets.Directory)
|
||||
@@ -164,6 +167,63 @@ pipelines:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileAcceptsExplicitWorkflows(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
workflow: additive
|
||||
- id: web
|
||||
backend: local
|
||||
path: /web
|
||||
workflow: replacement
|
||||
`)
|
||||
|
||||
destinations := cfg.Pipelines[0].Destinations
|
||||
if got, want := destinations[0].Workflow, WorkflowAdditive; got != want {
|
||||
t.Fatalf("archive workflow = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := destinations[1].Workflow, WorkflowReplacement; got != want {
|
||||
t.Fatalf("web workflow = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileAcceptsRetentionPruneConfig(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
retention:
|
||||
prune:
|
||||
enabled: true
|
||||
older_than: 168h
|
||||
keep_latest: 3
|
||||
`)
|
||||
|
||||
prune := cfg.Pipelines[0].Destinations[0].Retention.Prune
|
||||
if !prune.Enabled {
|
||||
t.Fatal("retention.prune.enabled = false, want true")
|
||||
}
|
||||
if prune.OlderThan == nil || prune.OlderThan.String() != "168h0m0s" {
|
||||
t.Fatalf("retention.prune.older_than = %v, want 168h", prune.OlderThan)
|
||||
}
|
||||
if prune.KeepLatest == nil || *prune.KeepLatest != 3 {
|
||||
t.Fatalf("retention.prune.keep_latest = %v, want 3", prune.KeepLatest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileAcceptsFixedPathMapping(t *testing.T) {
|
||||
cfg := loadConfig(t, `
|
||||
pipelines:
|
||||
@@ -256,13 +316,17 @@ pipelines:
|
||||
- id: weather-daily
|
||||
source:
|
||||
backend: http_upload
|
||||
token_env: WEATHER_DAILY_UPLOAD_TOKEN
|
||||
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
|
||||
@@ -289,9 +353,6 @@ pipelines:
|
||||
if got, want := source.Backend, BackendHTTPUpload; got != want {
|
||||
t.Fatalf("source.backend = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := source.Upload.TokenEnv, "WEATHER_DAILY_UPLOAD_TOKEN"; got != want {
|
||||
t.Fatalf("source.token_env = %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)
|
||||
}
|
||||
@@ -309,11 +370,15 @@ pipelines:
|
||||
- id: weather-daily
|
||||
source:
|
||||
backend: http_upload
|
||||
token_env: WEATHER_DAILY_UPLOAD_TOKEN
|
||||
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
|
||||
@@ -325,6 +390,120 @@ pipelines:
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
tests := map[string]string{
|
||||
"local": `
|
||||
@@ -515,16 +694,23 @@ func TestLoadFileRejectsInvalidS3Config(t *testing.T) {
|
||||
|
||||
func TestLoadFileRejectsInvalidHTTPUploadConfig(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"server size": `server: {http: {max_upload_size: 20XB}}`,
|
||||
"source size": `pipelines: [{id: reports, source: {backend: http_upload, token_env: UPLOAD_TOKEN, max_upload_size: 20XB}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"zero source size": `pipelines: [{id: reports, source: {backend: http_upload, token_env: UPLOAD_TOKEN, max_upload_size: 0B}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"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 token env": `pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"prune duration": `pipelines: [{id: reports, source: {backend: local, path: /source}, destinations: [{id: archive, backend: local, path: /archive, retention: {prune: {enabled: true, older_than: forever}}}]}]`,
|
||||
"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": `pipelines: [{id: reports, source: {backend: http_upload, token: secret, token_env: UPLOAD_TOKEN}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"unknown server field": `server: {http: {surprise: true}}`,
|
||||
"unknown source field": `pipelines: [{id: reports, source: {backend: http_upload, token_env: UPLOAD_TOKEN, surprise: true}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
|
||||
"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) {
|
||||
@@ -629,8 +815,63 @@ pipelines:
|
||||
`, "backend ftp is unsupported")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsInvalidTransferAction(t *testing.T) {
|
||||
func TestLoadFileRejectsInvalidWorkflow(t *testing.T) {
|
||||
assertLoadError(t, `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
workflow: append
|
||||
`, "workflow must be additive or replacement")
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsLegacyDestinationPolicyFields(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"state": `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
state:
|
||||
mode: single_owner
|
||||
`,
|
||||
"reconciliation": `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
reconciliation:
|
||||
mode: replace
|
||||
`,
|
||||
"takeover": `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: /source
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: /archive
|
||||
takeover:
|
||||
mode: same_pipeline
|
||||
`,
|
||||
"transfer": `
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
@@ -641,8 +882,14 @@ pipelines:
|
||||
backend: local
|
||||
path: /archive
|
||||
transfer:
|
||||
on_destination_older: overwrite
|
||||
`, "on_destination_older must be replace or fail")
|
||||
on_destination_older: replace
|
||||
`,
|
||||
}
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertLoadError(t, body, "field "+name+" not found")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsInvalidValidationAction(t *testing.T) {
|
||||
@@ -711,17 +958,14 @@ pipelines:
|
||||
}
|
||||
|
||||
func TestExampleConfigsLoad(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"../../examples/local-to-local.yml",
|
||||
"../../examples/local-publish.yml",
|
||||
"../../examples/local-html.yml",
|
||||
"../../examples/local-index.yml",
|
||||
"../../examples/fan-out.yml",
|
||||
"../../examples/archive-and-latest.yml",
|
||||
"../../examples/http-upload-local.yml",
|
||||
"../../examples/ssh-destination.yml",
|
||||
"../../examples/s3-destination.yml",
|
||||
} {
|
||||
paths, err := filepath.Glob("../../examples/*.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("glob examples: %v", err)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("no example configs found")
|
||||
}
|
||||
for _, path := range paths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
if _, err := LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile(%q) error = %v", path, err)
|
||||
|
||||
@@ -2,14 +2,20 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/link"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
|
||||
|
||||
func IsSlugLikeID(value string) bool {
|
||||
return idPattern.MatchString(value)
|
||||
}
|
||||
|
||||
type ValidationErrors []string
|
||||
|
||||
func (e ValidationErrors) Error() string {
|
||||
@@ -29,11 +35,12 @@ func Validate(cfg Config) error {
|
||||
}
|
||||
|
||||
pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines))
|
||||
uploadPipelineIDs := make(map[string]struct{})
|
||||
for pipelineIndex, pipeline := range cfg.Pipelines {
|
||||
pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex)
|
||||
if pipeline.ID == "" {
|
||||
errs = append(errs, pipelineContext+".id is required")
|
||||
} else if !idPattern.MatchString(pipeline.ID) {
|
||||
} else if !IsSlugLikeID(pipeline.ID) {
|
||||
errs = append(errs, pipelineContext+".id must be a slug-like identifier")
|
||||
} else if _, exists := pipelineIDs[pipeline.ID]; exists {
|
||||
errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated")
|
||||
@@ -42,6 +49,9 @@ func Validate(cfg Config) error {
|
||||
}
|
||||
|
||||
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)
|
||||
if len(pipeline.Destinations) == 0 {
|
||||
errs = append(errs, pipelineContext+".destinations is required")
|
||||
@@ -52,7 +62,7 @@ func Validate(cfg Config) error {
|
||||
destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex)
|
||||
if destination.ID == "" {
|
||||
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")
|
||||
} else if _, exists := destinationIDs[destination.ID]; exists {
|
||||
errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID)
|
||||
@@ -64,10 +74,13 @@ func Validate(cfg Config) error {
|
||||
errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform)
|
||||
errs = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap)
|
||||
errs = validateLinks(errs, destinationContext+".links", destination.Links)
|
||||
errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer)
|
||||
errs = validateWorkflow(errs, destinationContext+".workflow", destination.Workflow)
|
||||
errs = validateRetentionPolicy(errs, destinationContext+".retention", destination.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
errs = validateUploadTokens(errs, cfg.UploadTokens, pipelineIDs, uploadPipelineIDs)
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
@@ -100,7 +113,7 @@ func validateSourceBackend(errs ValidationErrors, context string, backend Backen
|
||||
if backend.Backend == BackendHTTPUpload {
|
||||
return validateHTTPUploadSource(errs, context, backend.Upload)
|
||||
}
|
||||
return validateBackend(errs, context, backend.Backend, backend.Host, backend.Port, backend.Path, backend.Endpoint, backend.Bucket, backend.Prefix, backend.SSH.HostKeyPolicy, backend.Creds)
|
||||
return validateBackend(errs, context, backendViewFromSource(backend))
|
||||
}
|
||||
|
||||
func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors {
|
||||
@@ -108,13 +121,10 @@ func validateDestinationBackend(errs ValidationErrors, context string, destinati
|
||||
errs = append(errs, context+".backend "+BackendHTTPUpload+" is only supported for sources")
|
||||
return errs
|
||||
}
|
||||
return validateBackend(errs, context, destination.Backend, destination.Host, destination.Port, destination.Path, destination.Endpoint, destination.Bucket, destination.Prefix, destination.SSH.HostKeyPolicy, destination.Creds)
|
||||
return validateBackend(errs, context, backendViewFromDestination(destination))
|
||||
}
|
||||
|
||||
func validateHTTPUploadSource(errs ValidationErrors, context string, upload HTTPUpload) ValidationErrors {
|
||||
if upload.TokenEnv == "" {
|
||||
errs = append(errs, context+".token_env is required for http_upload backend")
|
||||
}
|
||||
if upload.StagingPath == "" {
|
||||
errs = append(errs, context+".staging_path is required for http_upload backend")
|
||||
}
|
||||
@@ -124,47 +134,111 @@ func validateHTTPUploadSource(errs ValidationErrors, context string, upload HTTP
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, endpoint, bucket, prefix string, hostKeyPolicy HostKeyPolicy, creds Credentials) ValidationErrors {
|
||||
switch backend {
|
||||
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 "":
|
||||
errs = append(errs, context+".backend is required")
|
||||
case BackendLocal:
|
||||
if path == "" {
|
||||
if backend.Path == "" {
|
||||
errs = append(errs, context+".path is required for local backend")
|
||||
}
|
||||
case BackendSSH:
|
||||
if host == "" {
|
||||
if backend.Host == "" {
|
||||
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")
|
||||
}
|
||||
if port < 0 || port > 65535 {
|
||||
if backend.Port < 0 || backend.Port > 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")
|
||||
}
|
||||
if hostKeyPolicy != "" {
|
||||
if _, ok := NormalizeHostKeyPolicy(string(hostKeyPolicy)); !ok {
|
||||
if backend.SSH.HostKeyPolicy != "" {
|
||||
if _, ok := NormalizeHostKeyPolicy(string(backend.SSH.HostKeyPolicy)); !ok {
|
||||
errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false")
|
||||
}
|
||||
}
|
||||
case BackendS3:
|
||||
if endpoint == "" {
|
||||
if backend.Endpoint == "" {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
default:
|
||||
errs = append(errs, context+".backend "+backend+" is unsupported")
|
||||
errs = append(errs, context+".backend "+backend.Backend+" is unsupported")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -207,9 +281,15 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
|
||||
if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled {
|
||||
return fmt.Errorf("transform.markdown_to_html.input requires transform.markdown_to_html.enabled to be true")
|
||||
}
|
||||
if transform.MarkdownToHTML.CssHref != "" && !transform.MarkdownToHTML.Enabled {
|
||||
return fmt.Errorf("transform.markdown_to_html.css_href requires transform.markdown_to_html.enabled to be true")
|
||||
}
|
||||
if transform.MarkdownToHTML.Input != "" && mode != TransformModeIndex {
|
||||
return fmt.Errorf("transform.markdown_to_html.input is only valid when mode is %s", TransformModeIndex)
|
||||
}
|
||||
if err := validateCSSHref(transform.MarkdownToHTML.CssHref); err != nil {
|
||||
return fmt.Errorf("transform.markdown_to_html.css_href %w", err)
|
||||
}
|
||||
if transform.MarkdownToHTML.Enabled && !publish.HTML {
|
||||
return fmt.Errorf("transform.markdown_to_html.enabled requires publish.html to be true")
|
||||
}
|
||||
@@ -219,6 +299,49 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCSSHref(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) || unicode.IsSpace(character) {
|
||||
return fmt.Errorf("must not contain whitespace or control characters")
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(value, "\\<>\"'") {
|
||||
return fmt.Errorf("must not contain backslashes or HTML-sensitive characters")
|
||||
}
|
||||
if strings.HasPrefix(value, "//") {
|
||||
return fmt.Errorf("must not be scheme-relative")
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a valid URL reference: %w", err)
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return fmt.Errorf("must not include a fragment")
|
||||
}
|
||||
if parsed.Scheme != "" {
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("scheme must be http or https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return fmt.Errorf("host is required for absolute URLs")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("must not include userinfo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if parsed.Host != "" {
|
||||
return fmt.Errorf("must not be scheme-relative")
|
||||
}
|
||||
if parsed.Path == "" {
|
||||
return fmt.Errorf("relative URL path is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePathMapping(errs ValidationErrors, context string, mapping PathMapping) ValidationErrors {
|
||||
if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed {
|
||||
errs = append(errs, context+".mode must be "+PathMappingPreserveRelative+" or "+PathMappingFixed)
|
||||
@@ -243,18 +366,26 @@ func validateLinks(errs ValidationErrors, context string, links *Links) Validati
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors {
|
||||
if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail {
|
||||
errs = append(errs, context+".on_destination_same must be skip or fail")
|
||||
}
|
||||
if policy.OnDestinationOlder != TransferActionReplace && policy.OnDestinationOlder != TransferActionFail {
|
||||
errs = append(errs, context+".on_destination_older must be replace or fail")
|
||||
}
|
||||
if policy.OnDestinationNewer != TransferActionSkip && policy.OnDestinationNewer != TransferActionFail && policy.OnDestinationNewer != TransferActionReplace {
|
||||
errs = append(errs, context+".on_destination_newer must be skip, replace, or fail")
|
||||
}
|
||||
if policy.OnConflict != TransferActionFail && policy.OnConflict != TransferActionReplace {
|
||||
errs = append(errs, context+".on_conflict must be fail or replace")
|
||||
func validateWorkflow(errs ValidationErrors, context, workflow string) ValidationErrors {
|
||||
if workflow != WorkflowAdditive && workflow != WorkflowReplacement {
|
||||
errs = append(errs, context+" must be "+WorkflowAdditive+" or "+WorkflowReplacement)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateRetentionPolicy(errs ValidationErrors, context string, policy RetentionPolicy) ValidationErrors {
|
||||
prune := policy.Prune
|
||||
if !prune.Enabled {
|
||||
return errs
|
||||
}
|
||||
if prune.OlderThan == nil && prune.KeepLatest == nil {
|
||||
errs = append(errs, context+".prune must set older_than or keep_latest when enabled is true")
|
||||
}
|
||||
if prune.OlderThan != nil && *prune.OlderThan <= 0 {
|
||||
errs = append(errs, context+".prune.older_than must be greater than zero")
|
||||
}
|
||||
if prune.KeepLatest != nil && *prune.KeepLatest < 0 {
|
||||
errs = append(errs, context+".prune.keep_latest must be zero or greater")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidatePublishTransformPolicy(t *testing.T) {
|
||||
@@ -50,29 +51,6 @@ func TestValidateChecksPublishTransformPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsForceReplacementTransferActions(t *testing.T) {
|
||||
cfg := Config{Pipelines: []Pipeline{{
|
||||
ID: "reports",
|
||||
Source: Backend{
|
||||
Backend: BackendLocal,
|
||||
Path: "/source",
|
||||
},
|
||||
Destinations: []Destination{{
|
||||
ID: "archive",
|
||||
Backend: BackendLocal,
|
||||
Path: "/destination",
|
||||
Transfer: TransferPolicy{
|
||||
OnDestinationNewer: TransferActionReplace,
|
||||
OnConflict: TransferActionReplace,
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
ApplyDefaults(&cfg)
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePathMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -107,6 +85,106 @@ func TestValidatePathMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkflow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workflow string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "additive", workflow: WorkflowAdditive},
|
||||
{name: "replacement", workflow: WorkflowReplacement},
|
||||
{name: "invalid", workflow: "append", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Config{Pipelines: []Pipeline{{
|
||||
ID: "reports",
|
||||
Source: Backend{Backend: BackendLocal, Path: "/source"},
|
||||
Destinations: []Destination{{
|
||||
ID: "archive",
|
||||
Backend: BackendLocal,
|
||||
Path: "/destination",
|
||||
Workflow: tt.workflow,
|
||||
}},
|
||||
}}}
|
||||
ApplyDefaults(&cfg)
|
||||
err := Validate(cfg)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkflowReportsFieldContext(t *testing.T) {
|
||||
cfg := Config{Pipelines: []Pipeline{{
|
||||
ID: "reports",
|
||||
Source: Backend{Backend: BackendLocal, Path: "/source"},
|
||||
Destinations: []Destination{{
|
||||
ID: "archive",
|
||||
Backend: BackendLocal,
|
||||
Path: "/destination",
|
||||
Workflow: "append",
|
||||
}},
|
||||
}}}
|
||||
ApplyDefaults(&cfg)
|
||||
err := Validate(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
want := "pipelines[0].destinations[0].workflow must be additive or replacement"
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRetentionPolicy(t *testing.T) {
|
||||
olderThan := Duration(24 * time.Hour)
|
||||
zeroDuration := Duration(0)
|
||||
keepZero := 0
|
||||
keepThree := 3
|
||||
keepNegative := -1
|
||||
tests := []struct {
|
||||
name string
|
||||
retention RetentionPolicy
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "disabled"},
|
||||
{name: "older than", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, OlderThan: &olderThan}}},
|
||||
{name: "keep zero", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, KeepLatest: &keepZero}}},
|
||||
{name: "keep latest", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, KeepLatest: &keepThree}}},
|
||||
{name: "combined", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, OlderThan: &olderThan, KeepLatest: &keepThree}}},
|
||||
{name: "missing policy", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true}}, wantErr: true},
|
||||
{name: "zero older than", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, OlderThan: &zeroDuration}}, wantErr: true},
|
||||
{name: "negative keep latest", retention: RetentionPolicy{Prune: PrunePolicy{Enabled: true, KeepLatest: &keepNegative}}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Config{Pipelines: []Pipeline{{
|
||||
ID: "reports",
|
||||
Source: Backend{Backend: BackendLocal, Path: "/source"},
|
||||
Destinations: []Destination{{
|
||||
ID: "archive",
|
||||
Backend: BackendLocal,
|
||||
Path: "/destination",
|
||||
Retention: tt.retention,
|
||||
}},
|
||||
}}}
|
||||
ApplyDefaults(&cfg)
|
||||
err := Validate(cfg)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -215,6 +293,24 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
Input: "report.md",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "html only sidecar css href allowed",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: TransformModeSidecar,
|
||||
CssHref: "/assets/report.css",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "html only index css href allowed",
|
||||
publish: PublishPolicy{HTML: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: true,
|
||||
Mode: TransformModeIndex,
|
||||
CssHref: "assets/report.css?v=20260614",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "source and html sidecar allowed",
|
||||
publish: PublishPolicy{Source: true, HTML: true},
|
||||
@@ -312,6 +408,16 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled markdown css href rejected",
|
||||
publish: PublishPolicy{Source: true},
|
||||
transform: Transform{MarkdownToHTML: &MarkdownToHTML{
|
||||
Enabled: false,
|
||||
Mode: TransformModeSidecar,
|
||||
CssHref: "/assets/report.css",
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "disabled markdown wrong mode rejected",
|
||||
publish: PublishPolicy{Source: true},
|
||||
@@ -323,3 +429,44 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCSSHref(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty"},
|
||||
{name: "root relative", value: "/assets/report.css"},
|
||||
{name: "relative", value: "assets/report.css"},
|
||||
{name: "parent relative", value: "../assets/report.css"},
|
||||
{name: "query", value: "/assets/report.css?v=20260614"},
|
||||
{name: "http", value: "http://example.com/report.css"},
|
||||
{name: "https", value: "https://example.com/assets/report.css?v=1"},
|
||||
{name: "javascript", value: "javascript:alert(1)", wantErr: true},
|
||||
{name: "data", value: "data:text/css,body{}", wantErr: true},
|
||||
{name: "file", value: "file:///tmp/report.css", wantErr: true},
|
||||
{name: "scheme relative", value: "//example.com/report.css", wantErr: true},
|
||||
{name: "userinfo", value: "https://user@example.com/report.css", wantErr: true},
|
||||
{name: "fragment", value: "/assets/report.css#main", wantErr: true},
|
||||
{name: "space", value: "/assets/report css", wantErr: true},
|
||||
{name: "tab", value: "/assets/report\tcss", wantErr: true},
|
||||
{name: "newline", value: "/assets/report\ncss", wantErr: true},
|
||||
{name: "backslash", value: `assets\report.css`, wantErr: true},
|
||||
{name: "less than", value: "/assets/<report>.css", wantErr: true},
|
||||
{name: "double quote", value: `/assets/"report".css`, wantErr: true},
|
||||
{name: "single quote", value: "/assets/'report'.css", wantErr: true},
|
||||
{name: "query only", value: "?v=1", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCSSHref(tt.value)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("validateCSSHref() error = nil, want error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("validateCSSHref() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ func validateRunID(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateContentType(contentType string) error {
|
||||
_, err := archiveFormat(contentType)
|
||||
return err
|
||||
}
|
||||
|
||||
type archiveKind int
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -47,6 +48,25 @@ func TestStageArchiveRejectsUnsupportedContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -94,9 +114,19 @@ func TestStageArchiveRejectsUnsafeEntries(t *testing.T) {
|
||||
"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"},
|
||||
},
|
||||
@@ -106,6 +136,12 @@ func TestStageArchiveRejectsUnsafeEntries(t *testing.T) {
|
||||
"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) {
|
||||
@@ -127,6 +163,14 @@ func TestStageArchiveRejectsBundleValidationFailures(t *testing.T) {
|
||||
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"}))),
|
||||
},
|
||||
@@ -149,6 +193,20 @@ func TestStageArchiveRejectsBundleValidationFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
@@ -336,6 +394,15 @@ func manifestJSON(t *testing.T, manifest sourcebundle.Manifest) string {
|
||||
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))
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
@@ -12,24 +12,16 @@ import (
|
||||
|
||||
func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
switch plan.Action {
|
||||
case ActionSkipSame, ActionSkipDestinationNewer:
|
||||
case ActionSkipSame:
|
||||
return nil
|
||||
case ActionPublishNew, ActionReplaceOlder, ActionForceReplace:
|
||||
case ActionPublishNew, ActionUpsertAdditive, ActionReplaceCatalog, ActionForceReplace:
|
||||
return executeCatalog(ctx, req, plan)
|
||||
default:
|
||||
return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
if plan.Action == ActionReplaceOlder {
|
||||
if plan.ExistingState == nil {
|
||||
return fmt.Errorf("replace requires existing destination state")
|
||||
}
|
||||
if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, stateOutputManagedPaths(plan.ExistingState.Outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
func executeCatalog(ctx context.Context, req Request, plan Plan) error {
|
||||
if plan.Action == ActionForceReplace {
|
||||
if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
@@ -37,11 +29,29 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if plan.Action == ActionReplaceCatalog {
|
||||
if plan.ClearDestinationRoot {
|
||||
if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(plan.CatalogOutputsToDelete) > 0 {
|
||||
if err := req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, catalogOutputPaths(plan.CatalogOutputsToDelete), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writtenOutputs := make([]Output, 0, len(plan.Outputs))
|
||||
newOutputs := make([]Output, 0, len(plan.Outputs))
|
||||
cleanup := func() {
|
||||
_ = req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, ManagedOutputPaths(writtenOutputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
|
||||
outputs := writtenOutputs
|
||||
if plan.Action == ActionUpsertAdditive {
|
||||
outputs = newOutputs
|
||||
}
|
||||
_ = req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, ManagedOutputPaths(outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
|
||||
}
|
||||
for _, output := range plan.Outputs {
|
||||
destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath)
|
||||
@@ -49,6 +59,11 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
created, err := catalogWriteCreatesOutput(ctx, req.DestinationBackend, destinationPath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
data := output.Data
|
||||
if output.Kind == state.OutputKindSource {
|
||||
sourcePath, err := storage.Join(req.SourceBundle.RootRelativePath, output.SourcePath)
|
||||
@@ -62,30 +77,22 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := req.DestinationBackend.WriteFile(ctx, destinationPath, data, storage.WriteOptions{Overwrite: false, PreferAtomic: true}); err != nil {
|
||||
if _, err := req.DestinationBackend.WriteFile(ctx, destinationPath, data, storage.WriteOptions{Overwrite: catalogOutputOverwriteAllowed(plan, output), PreferAtomic: true}); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
writtenOutputs = append(writtenOutputs, output)
|
||||
if created {
|
||||
newOutputs = append(newOutputs, output)
|
||||
}
|
||||
}
|
||||
|
||||
destinationState := state.DistributorState{
|
||||
SchemaVersion: state.SchemaVersion,
|
||||
DistributorVersion: req.DistributorVersion,
|
||||
PipelineID: req.PipelineID,
|
||||
DestinationID: req.DestinationID,
|
||||
PublishedAt: time.Now().UTC(),
|
||||
Source: state.SourceState{Manifest: req.SourceBundle.Manifest},
|
||||
Outputs: StateOutputFiles(plan.Outputs),
|
||||
}
|
||||
if plan.PrimaryURL != "" {
|
||||
destinationState.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL}
|
||||
}
|
||||
if err := state.Validate(destinationState); err != nil {
|
||||
catalogState := catalogStateForPlan(req, plan)
|
||||
if err := state.ValidateCatalog(catalogState); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(destinationState, "", " ")
|
||||
data, err := json.MarshalIndent(catalogState, "", " ")
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
@@ -96,9 +103,73 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if _, err := req.DestinationBackend.WriteFile(ctx, statePath, data, storage.WriteOptions{Overwrite: false, PreferAtomic: true}); err != nil {
|
||||
if _, err := req.DestinationBackend.WriteFile(ctx, statePath, data, storage.WriteOptions{Overwrite: catalogStateWriteOverwrites(plan), PreferAtomic: true}); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func catalogWriteCreatesOutput(ctx context.Context, backend storage.Backend, destinationPath string) (bool, error) {
|
||||
if _, err := backend.Stat(ctx, destinationPath); err == nil {
|
||||
return false, nil
|
||||
} else if storage.IsNotFound(err) {
|
||||
return true, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func catalogOutputOverwriteAllowed(plan Plan, output Output) bool {
|
||||
if plan.ClearDestinationRoot {
|
||||
return false
|
||||
}
|
||||
if plan.SupersededLegacy != nil {
|
||||
return true
|
||||
}
|
||||
if plan.ExistingCatalog == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := state.FindCatalogOutputByPath(plan.ExistingCatalog.Outputs, output.DestinationPath)
|
||||
return ok
|
||||
}
|
||||
|
||||
func catalogStateForPlan(req Request, plan Plan) state.CatalogState {
|
||||
now := requestTime(req)
|
||||
createdAt := now
|
||||
if plan.ExistingCatalog != nil {
|
||||
createdAt = plan.ExistingCatalog.CreatedAt
|
||||
}
|
||||
outputs := make([]state.CatalogOutputFile, 0, len(plan.CatalogOutputsToRetain)+len(plan.CatalogOutputsToWrite))
|
||||
outputs = append(outputs, plan.CatalogOutputsToRetain...)
|
||||
outputs = append(outputs, plan.CatalogOutputsToWrite...)
|
||||
sort.SliceStable(outputs, func(i, j int) bool {
|
||||
if outputs[i].Path != outputs[j].Path {
|
||||
return outputs[i].Path < outputs[j].Path
|
||||
}
|
||||
if outputs[i].PipelineID != outputs[j].PipelineID {
|
||||
return outputs[i].PipelineID < outputs[j].PipelineID
|
||||
}
|
||||
return outputs[i].DestinationID < outputs[j].DestinationID
|
||||
})
|
||||
return state.CatalogState{
|
||||
SchemaVersion: state.CatalogSchemaVersion,
|
||||
DistributorVersion: req.DistributorVersion,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
State: state.StatePolicy{Mode: state.StateModeCatalog},
|
||||
Outputs: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
func catalogStateWriteOverwrites(plan Plan) bool {
|
||||
return plan.ExistingCatalog != nil || plan.SupersededLegacy != nil
|
||||
}
|
||||
|
||||
func catalogOutputPaths(outputs []state.CatalogOutputFile) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -2,63 +2,226 @@ package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestExecuteCleansUpAfterWriteFailure(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
destinationBackend := &failingBackend{Backend: fake.New(), failPath: "summary.txt"}
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{})
|
||||
req := Request{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
SourceBundle: sourceBundle,
|
||||
SourceBackend: sourceBackend,
|
||||
DestinationBackend: destinationBackend,
|
||||
DestinationBundlePath: "",
|
||||
Publish: config.PublishPolicy{Source: true},
|
||||
Transfer: config.TransferPolicy{OnDestinationSame: config.TransferActionSkip, OnDestinationOlder: config.TransferActionReplace, OnDestinationNewer: config.TransferActionSkip, OnConflict: config.TransferActionFail},
|
||||
DistributorVersion: "test",
|
||||
func TestExecuteAdditiveWritesOutputsAndCatalog(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
|
||||
existing := baseCatalog(req)
|
||||
existing.Outputs = []state.CatalogOutputFile{
|
||||
catalogOutput(req, "reports", "archive", "report.md", state.OutputKindSource, planCreatedAt),
|
||||
catalogOutput(req, "reports", "web", "old.txt", state.OutputKindSource, planCreatedAt),
|
||||
}
|
||||
writeCatalogState(t, destinationBackend, "", existing)
|
||||
testutil.WriteFakeFile(t, destinationBackend, "report.md", "old report")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "old.txt", "retained")
|
||||
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "summary.txt", "Summary\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "old.txt", "retained")
|
||||
catalog := readCatalogState(t, destinationBackend, "")
|
||||
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
|
||||
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
|
||||
}
|
||||
if len(catalog.Outputs) != 3 {
|
||||
t.Fatalf("catalog outputs = %#v, want three outputs", catalog.Outputs)
|
||||
}
|
||||
report, ok := state.FindCatalogOutputByPath(catalog.Outputs, "report.md")
|
||||
if !ok {
|
||||
t.Fatalf("catalog outputs = %#v, want report.md", catalog.Outputs)
|
||||
}
|
||||
if !report.CreatedAt.Equal(planCreatedAt) || !report.UpdatedAt.Equal(planUpdatedAt) {
|
||||
t.Fatalf("report times = %s/%s, want created preserved and updated now", report.CreatedAt, report.UpdatedAt)
|
||||
}
|
||||
if report.SourcePath != "" {
|
||||
t.Fatalf("source catalog output source_path = %q, want empty", report.SourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReplacementDeletesCurrentOwnerAndPreservesOtherOwners(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowReplacement)
|
||||
existing := baseCatalog(req)
|
||||
existing.Outputs = []state.CatalogOutputFile{
|
||||
catalogOutput(req, "reports", "archive", "report.md", state.OutputKindSource, planCreatedAt),
|
||||
catalogOutput(req, "reports", "archive", "stale.txt", state.OutputKindSource, planCreatedAt),
|
||||
catalogOutput(req, "reports", "web", "shared.txt", state.OutputKindSource, planCreatedAt),
|
||||
}
|
||||
writeCatalogState(t, destinationBackend, "", existing)
|
||||
testutil.WriteFakeFile(t, destinationBackend, "report.md", "old report")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "stale.txt", "delete me")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "shared.txt", "keep me")
|
||||
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "summary.txt", "Summary\n")
|
||||
testutil.AssertFakeMissing(t, destinationBackend, "stale.txt")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "shared.txt", "keep me")
|
||||
catalog := readCatalogState(t, destinationBackend, "")
|
||||
if _, ok := state.FindCatalogOutputByPath(catalog.Outputs, "stale.txt"); ok {
|
||||
t.Fatalf("catalog outputs = %#v, want stale.txt removed", catalog.Outputs)
|
||||
}
|
||||
if _, ok := state.FindCatalogOutputByPath(catalog.Outputs, "shared.txt"); !ok {
|
||||
t.Fatalf("catalog outputs = %#v, want shared.txt retained", catalog.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSupersededReplacementClearsDestinationRootOnly(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowReplacement)
|
||||
req.DestinationBundlePath = "bundle"
|
||||
legacyState := legacyStateDocument(2)
|
||||
writeJSONState(t, destinationBackend, req.DestinationBundlePath, legacyState)
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle/unplanned.txt", "remove")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "outside.txt", "keep")
|
||||
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
testutil.AssertFakeMissing(t, destinationBackend, "bundle/unplanned.txt")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "outside.txt", "keep")
|
||||
readCatalogState(t, destinationBackend, "bundle")
|
||||
}
|
||||
|
||||
func TestExecuteSupersededAdditiveLeavesUnplannedFilesUnmanaged(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
|
||||
legacyState := legacyStateDocument(2)
|
||||
writeJSONState(t, destinationBackend, "", legacyState)
|
||||
testutil.WriteFakeFile(t, destinationBackend, "report.md", "legacy report")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "leave me")
|
||||
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "unplanned.txt", "leave me")
|
||||
catalog := readCatalogState(t, destinationBackend, "")
|
||||
if _, ok := state.FindCatalogOutputByPath(catalog.Outputs, "unplanned.txt"); ok {
|
||||
t.Fatalf("catalog outputs = %#v, want unplanned file omitted", catalog.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteForceReplaceClearsDestinationBundlePathOnly(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
|
||||
req.DestinationBundlePath = "bundle"
|
||||
req.Force = true
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle/report.md", "old report")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle/unplanned.txt", "remove")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle-child/keep.txt", "keep")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "outside.txt", "keep")
|
||||
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if plan.Action != ActionForceReplace {
|
||||
t.Fatalf("plan action = %s, want %s", plan.Action, ActionForceReplace)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
testutil.AssertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "bundle/summary.txt", "Summary\n")
|
||||
testutil.AssertFakeMissing(t, destinationBackend, "bundle/unplanned.txt")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "bundle-child/keep.txt", "keep")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "outside.txt", "keep")
|
||||
catalog := readCatalogState(t, destinationBackend, "bundle")
|
||||
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
|
||||
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
|
||||
}
|
||||
if len(catalog.Outputs) != 2 {
|
||||
t.Fatalf("catalog outputs = %#v, want planned outputs only", catalog.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteForceReplaceClearsFixedDestinationRoot(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
|
||||
req.DestinationBundlePath = ""
|
||||
req.PathMapping = config.PathMappingFixed
|
||||
req.Force = true
|
||||
testutil.WriteFakeFile(t, destinationBackend, "report.md", "old report")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "remove")
|
||||
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if plan.Action != ActionForceReplace || storage.DisplayPath(plan.DestinationBundlePath) != "." {
|
||||
t.Fatalf("plan action=%s destination=%s, want force_replace at root", plan.Action, storage.DisplayPath(plan.DestinationBundlePath))
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "summary.txt", "Summary\n")
|
||||
testutil.AssertFakeMissing(t, destinationBackend, "unplanned.txt")
|
||||
readCatalogState(t, destinationBackend, "")
|
||||
}
|
||||
|
||||
func TestExecuteFailedWriteDoesNotWriteCatalogState(t *testing.T) {
|
||||
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if err := destinationBackend.AddDirectory("report.md"); err != nil {
|
||||
t.Fatalf("add conflicting directory: %v", err)
|
||||
}
|
||||
|
||||
err = Execute(context.Background(), req, plan)
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want error")
|
||||
t.Fatal("Execute() error = nil, want write failure")
|
||||
}
|
||||
found, err := destinationBackend.HasAny(context.Background(), "")
|
||||
if _, statErr := destinationBackend.Stat(context.Background(), storage.StateFileName); !storage.IsNotFound(statErr) {
|
||||
t.Fatalf("state stat error = %v, want missing state", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func readCatalogState(t *testing.T, backend *fake.Backend, relative string) state.CatalogState {
|
||||
t.Helper()
|
||||
statePath, err := storage.StatePath(relative)
|
||||
if err != nil {
|
||||
t.Fatalf("HasAny() error = %v", err)
|
||||
t.Fatalf("state path: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("destination has content after failed execution")
|
||||
data, err := backend.ReadFile(context.Background(), statePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read catalog state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingBackend struct {
|
||||
*fake.Backend
|
||||
failPath string
|
||||
}
|
||||
|
||||
func (b *failingBackend) WriteFile(ctx context.Context, path string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if path == b.failPath {
|
||||
return storage.Entry{}, fmt.Errorf("injected write failure")
|
||||
catalog, err := state.ParseCatalog(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse catalog state: %v", err)
|
||||
}
|
||||
return b.Backend.WriteFile(ctx, path, data, opts)
|
||||
}
|
||||
|
||||
func (b *failingBackend) WriteFrom(ctx context.Context, path string, r io.Reader, opts storage.WriteOptions) (storage.Entry, error) {
|
||||
if path == b.failPath {
|
||||
return storage.Entry{}, fmt.Errorf("injected write failure")
|
||||
}
|
||||
return b.Backend.WriteFrom(ctx, path, r, opts)
|
||||
return catalog
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, backend *fake.Backend, source bundle.Manifest)
|
||||
transfer config.TransferPolicy
|
||||
wantReason string
|
||||
forceAction bool
|
||||
}{
|
||||
{
|
||||
name: "unmanaged content",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
testutil.WriteFakeFile(t, backend, "bundle/old.txt", "old")
|
||||
},
|
||||
transfer: defaultTransfer(),
|
||||
wantReason: "fail_unmanaged",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "different source id",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
conflict := source
|
||||
conflict.ID = "other.source"
|
||||
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "same created digest conflict",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}})
|
||||
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "pipeline mismatch",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "destination mismatch",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"})
|
||||
},
|
||||
transfer: conflictReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
{
|
||||
name: "newer destination",
|
||||
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
|
||||
t.Helper()
|
||||
newer := source
|
||||
newer.Created = newer.Created.AddDate(0, 0, 1)
|
||||
testutil.WriteFakeDestinationState(t, backend, "bundle", newer, testutil.DestinationStateOptions{})
|
||||
},
|
||||
transfer: newerReplaceTransfer(),
|
||||
wantReason: "requires --force",
|
||||
forceAction: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
|
||||
destinationBackend := fake.New()
|
||||
tt.prepare(t, destinationBackend, sourceBundle.Manifest)
|
||||
|
||||
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, tt.transfer)
|
||||
_, err := Build(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantReason) {
|
||||
t.Fatalf("Build() error = %v, want %q", err, tt.wantReason)
|
||||
}
|
||||
|
||||
req.Force = true
|
||||
plan, err := Build(context.Background(), req)
|
||||
if tt.forceAction {
|
||||
if err != nil {
|
||||
t.Fatalf("Build() with force error = %v", err)
|
||||
}
|
||||
if plan.Action != ActionForceReplace || !plan.Force {
|
||||
t.Fatalf("forced plan action = %s force=%t", plan.Action, plan.Force)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequiresConflictPolicyForStateConflicts(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
|
||||
destinationBackend := fake.New()
|
||||
conflict := sourceBundle.Manifest
|
||||
conflict.ID = "other.source"
|
||||
testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", conflict, testutil.DestinationStateOptions{})
|
||||
|
||||
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, defaultTransfer())
|
||||
req.Force = true
|
||||
_, err := Build(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "destination source id differs") {
|
||||
t.Fatalf("Build() error = %v, want conservative conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteForcedReplacementDeletesOnlyBundlePath(t *testing.T) {
|
||||
sourceBackend := fake.New()
|
||||
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
|
||||
destinationBackend := fake.New()
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle/old.txt", "old")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle/nested/old.txt", "old")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "bundle-sibling/keep.txt", "keep")
|
||||
testutil.WriteFakeFile(t, destinationBackend, "outside.txt", "outside")
|
||||
|
||||
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, defaultTransfer())
|
||||
req.Force = true
|
||||
plan, err := Build(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if plan.Action != ActionForceReplace {
|
||||
t.Fatalf("plan action = %s, want force_replace", plan.Action)
|
||||
}
|
||||
if err := Execute(context.Background(), req, plan); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
testutil.AssertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nSunny.\n")
|
||||
testutil.AssertFakeMissing(t, destinationBackend, "bundle/old.txt")
|
||||
testutil.AssertFakeMissing(t, destinationBackend, "bundle/nested/old.txt")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "bundle-sibling/keep.txt", "keep")
|
||||
testutil.AssertFakeFile(t, destinationBackend, "outside.txt", "outside")
|
||||
}
|
||||
|
||||
func forceRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle bundle.Bundle, transfer config.TransferPolicy) Request {
|
||||
return Request{
|
||||
PipelineID: "reports",
|
||||
DestinationID: "archive",
|
||||
SourceBundle: sourceBundle,
|
||||
SourceBackend: sourceBackend,
|
||||
DestinationBackend: destinationBackend,
|
||||
DestinationBundlePath: sourceBundle.RootRelativePath,
|
||||
Publish: config.PublishPolicy{Source: true},
|
||||
Transfer: transfer,
|
||||
DistributorVersion: "test",
|
||||
}
|
||||
}
|
||||
|
||||
func defaultTransfer() config.TransferPolicy {
|
||||
return config.TransferPolicy{
|
||||
OnDestinationSame: config.TransferActionSkip,
|
||||
OnDestinationOlder: config.TransferActionReplace,
|
||||
OnDestinationNewer: config.TransferActionSkip,
|
||||
OnConflict: config.TransferActionFail,
|
||||
}
|
||||
}
|
||||
|
||||
func conflictReplaceTransfer() config.TransferPolicy {
|
||||
transfer := defaultTransfer()
|
||||
transfer.OnConflict = config.TransferActionReplace
|
||||
return transfer
|
||||
}
|
||||
|
||||
func newerReplaceTransfer() config.TransferPolicy {
|
||||
transfer := defaultTransfer()
|
||||
transfer.OnDestinationNewer = config.TransferActionReplace
|
||||
return transfer
|
||||
}
|
||||
@@ -27,8 +27,9 @@ func PlanOutputs(ctx context.Context, req Request) ([]Output, error) {
|
||||
SourceBundle: req.SourceBundle,
|
||||
SourceBackend: req.SourceBackend,
|
||||
Markdown: transform.MarkdownOptions{
|
||||
Mode: req.Transform.MarkdownToHTML.Mode,
|
||||
Input: req.Transform.MarkdownToHTML.Input,
|
||||
Mode: req.Transform.MarkdownToHTML.Mode,
|
||||
Input: req.Transform.MarkdownToHTML.Input,
|
||||
CssHref: req.Transform.MarkdownToHTML.CssHref,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -97,30 +98,10 @@ func rejectOutputCollisions(outputs []Output) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o Output) StateOutputFile() state.OutputFile {
|
||||
return state.OutputFile{
|
||||
Path: o.DestinationPath,
|
||||
Kind: o.Kind,
|
||||
SourcePath: o.SourcePath,
|
||||
Transform: o.Transform,
|
||||
URL: o.URL,
|
||||
SHA256: o.SHA256,
|
||||
Size: o.Size,
|
||||
}
|
||||
}
|
||||
|
||||
func (o Output) ManagedPath() string {
|
||||
return o.DestinationPath
|
||||
}
|
||||
|
||||
func StateOutputFiles(outputs []Output) []state.OutputFile {
|
||||
files := make([]state.OutputFile, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
files = append(files, output.StateOutputFile())
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func ManagedOutputPaths(outputs []Output) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
@@ -128,11 +109,3 @@ func ManagedOutputPaths(outputs []Output) []string {
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func stateOutputManagedPaths(outputs []state.OutputFile) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -12,46 +12,12 @@ import (
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/transform"
|
||||
)
|
||||
|
||||
func TestOutputStateProjection(t *testing.T) {
|
||||
sourceOutput := Output{
|
||||
SourcePath: "report.md",
|
||||
DestinationPath: "report.md",
|
||||
Kind: state.OutputKindSource,
|
||||
URL: "https://reports.example.com/report.md",
|
||||
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
Size: 123,
|
||||
}
|
||||
sourceState := sourceOutput.StateOutputFile()
|
||||
if sourceState.Path != "report.md" || sourceState.Kind != state.OutputKindSource || sourceState.SourcePath != "report.md" || sourceState.URL != sourceOutput.URL || sourceState.SHA256 != sourceOutput.SHA256 || sourceState.Size != sourceOutput.Size {
|
||||
t.Fatalf("source state output = %#v", sourceState)
|
||||
}
|
||||
|
||||
generatedOutput := Output{
|
||||
SourcePath: "report.md",
|
||||
DestinationPath: "report.html",
|
||||
Kind: state.OutputKindGenerated,
|
||||
Transform: transform.MarkdownToHTML,
|
||||
URL: "https://reports.example.com/report.html",
|
||||
SHA256: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
Size: 456,
|
||||
}
|
||||
generatedState := generatedOutput.StateOutputFile()
|
||||
if generatedState.Path != "report.html" || generatedState.Kind != state.OutputKindGenerated || generatedState.SourcePath != "report.md" || generatedState.Transform != transform.MarkdownToHTML || generatedState.URL != generatedOutput.URL || generatedState.SHA256 != generatedOutput.SHA256 || generatedState.Size != generatedOutput.Size {
|
||||
t.Fatalf("generated state output = %#v", generatedState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputSliceProjections(t *testing.T) {
|
||||
func TestManagedOutputPaths(t *testing.T) {
|
||||
outputs := []Output{
|
||||
{SourcePath: "report.md", DestinationPath: "report.md", Kind: state.OutputKindSource, SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 1},
|
||||
{SourcePath: "report.md", DestinationPath: "report.html", Kind: state.OutputKindGenerated, Transform: transform.MarkdownToHTML, URL: "https://reports.example.com/report.html", SHA256: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Size: 2},
|
||||
}
|
||||
|
||||
stateOutputs := StateOutputFiles(outputs)
|
||||
if len(stateOutputs) != 2 || stateOutputs[1].Path != "report.html" || stateOutputs[1].Transform != transform.MarkdownToHTML || stateOutputs[1].URL != outputs[1].URL {
|
||||
t.Fatalf("state outputs = %#v", stateOutputs)
|
||||
}
|
||||
|
||||
paths := ManagedOutputPaths(outputs)
|
||||
if len(paths) != 2 || paths[0] != "report.md" || paths[1] != "report.html" {
|
||||
t.Fatalf("managed paths = %#v", paths)
|
||||
@@ -167,6 +133,7 @@ func TestPlanOutputsPassesMarkdownOptions(t *testing.T) {
|
||||
Enabled: true,
|
||||
Mode: config.TransformModeIndex,
|
||||
Input: "report.md",
|
||||
CssHref: "/assets/report.css",
|
||||
}},
|
||||
Transformers: testResolver{transform.MarkdownToHTML: transformer},
|
||||
})
|
||||
@@ -174,8 +141,8 @@ func TestPlanOutputsPassesMarkdownOptions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("PlanOutputs() error = %v", err)
|
||||
}
|
||||
if transformer.request.Markdown.Mode != config.TransformModeIndex || transformer.request.Markdown.Input != "report.md" {
|
||||
t.Fatalf("markdown options = %#v, want index/report.md", transformer.request.Markdown)
|
||||
if transformer.request.Markdown.Mode != config.TransformModeIndex || transformer.request.Markdown.Input != "report.md" || transformer.request.Markdown.CssHref != "/assets/report.css" {
|
||||
t.Fatalf("markdown options = %#v, want index/report.md with css href", transformer.request.Markdown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,12 +160,6 @@ func TestBuildRejectsHTMLWithoutTransform(t *testing.T) {
|
||||
DestinationBundlePath: "",
|
||||
SourceBundle: sourceBundle,
|
||||
Publish: config.PublishPolicy{HTML: true},
|
||||
Transfer: config.TransferPolicy{
|
||||
OnDestinationSame: config.TransferActionSkip,
|
||||
OnDestinationOlder: config.TransferActionReplace,
|
||||
OnDestinationNewer: config.TransferActionSkip,
|
||||
OnConflict: config.TransferActionFail,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Build() error = nil, want missing transform error")
|
||||
|
||||
@@ -3,6 +3,7 @@ package publish
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
@@ -14,13 +15,13 @@ import (
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionPublishNew Action = "publish_new"
|
||||
ActionReplaceOlder Action = "replace_older"
|
||||
ActionSkipSame Action = "skip_same"
|
||||
ActionSkipDestinationNewer Action = "skip_destination_newer"
|
||||
ActionFailConflict Action = "fail_conflict"
|
||||
ActionFailUnmanaged Action = "fail_unmanaged"
|
||||
ActionForceReplace Action = "force_replace"
|
||||
ActionPublishNew Action = "publish_new"
|
||||
ActionSkipSame Action = "skip_same"
|
||||
ActionFailConflict Action = "fail_conflict"
|
||||
ActionFailUnmanaged Action = "fail_unmanaged"
|
||||
ActionForceReplace Action = "force_replace"
|
||||
ActionUpsertAdditive Action = "upsert_additive"
|
||||
ActionReplaceCatalog Action = "replace_catalog"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
@@ -34,31 +35,17 @@ type Request struct {
|
||||
Publish config.PublishPolicy
|
||||
Transform config.Transform
|
||||
Links *config.Links
|
||||
Workflow string
|
||||
Transformers TransformerResolver
|
||||
Transfer config.TransferPolicy
|
||||
DistributorVersion string
|
||||
Force bool
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type TransformerResolver interface {
|
||||
Get(name string) (transform.Transformer, bool)
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
BundleID string
|
||||
BundlePath string
|
||||
DestinationBundlePath string
|
||||
PathMapping string
|
||||
Action Action
|
||||
Reason string
|
||||
Force bool
|
||||
PrimaryURL string
|
||||
Outputs []Output
|
||||
ExistingState *state.DistributorState
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
SourcePath string
|
||||
DestinationPath string
|
||||
@@ -70,6 +57,37 @@ type Output struct {
|
||||
Size int64
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
PipelineID string
|
||||
DestinationID string
|
||||
BundleID string
|
||||
BundlePath string
|
||||
DestinationBundlePath string
|
||||
PathMapping string
|
||||
Action Action
|
||||
Reason string
|
||||
Force bool
|
||||
PrimaryURL string
|
||||
Workflow string
|
||||
OwnerScope state.OwnerScope
|
||||
Outputs []Output
|
||||
ExistingCatalog *state.CatalogState
|
||||
SupersededLegacy *state.SupersededLegacyState
|
||||
CatalogOutputsToWrite []state.CatalogOutputFile
|
||||
CatalogOutputsToRetain []state.CatalogOutputFile
|
||||
CatalogOutputsToDelete []state.CatalogOutputFile
|
||||
ClearDestinationRoot bool
|
||||
}
|
||||
|
||||
type catalogPlanDetails struct {
|
||||
Action Action
|
||||
Reason string
|
||||
CatalogOutputsToWrite []state.CatalogOutputFile
|
||||
CatalogOutputsToRetain []state.CatalogOutputFile
|
||||
CatalogOutputsToDelete []state.CatalogOutputFile
|
||||
ClearDestinationRoot bool
|
||||
}
|
||||
|
||||
func Build(ctx context.Context, req Request) (Plan, error) {
|
||||
if err := validateRequest(req); err != nil {
|
||||
return Plan{}, err
|
||||
@@ -86,8 +104,9 @@ func Build(ctx context.Context, req Request) (Plan, error) {
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
comparison := compareDestination(req, status)
|
||||
action, reason := actionForComparison(comparison, req.Transfer, req.Force)
|
||||
workflow := normalizeWorkflow(req.Workflow)
|
||||
scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
|
||||
now := requestTime(req)
|
||||
plan := Plan{
|
||||
PipelineID: req.PipelineID,
|
||||
DestinationID: req.DestinationID,
|
||||
@@ -95,15 +114,52 @@ func Build(ctx context.Context, req Request) (Plan, error) {
|
||||
BundlePath: req.SourceBundle.RootRelativePath,
|
||||
DestinationBundlePath: req.DestinationBundlePath,
|
||||
PathMapping: req.PathMapping,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
Force: action == ActionForceReplace,
|
||||
PrimaryURL: primaryURL,
|
||||
Workflow: workflow,
|
||||
OwnerScope: scope,
|
||||
Outputs: outputs,
|
||||
ExistingState: status.State,
|
||||
ExistingCatalog: status.Catalog,
|
||||
SupersededLegacy: status.SupersededLegacy,
|
||||
}
|
||||
if action == ActionFailConflict || action == ActionFailUnmanaged {
|
||||
return plan, fmt.Errorf("%s: %s", action, reason)
|
||||
if status.StateErr != nil {
|
||||
plan.Reason = status.StateErr.Error()
|
||||
if req.Force {
|
||||
plan.Action = ActionForceReplace
|
||||
plan.Force = true
|
||||
plan.ClearDestinationRoot = true
|
||||
plan.CatalogOutputsToWrite = catalogOutputsForPlan(req, outputs, nil, scope, now)
|
||||
return plan, nil
|
||||
}
|
||||
plan.Action = ActionFailConflict
|
||||
return plan, fmt.Errorf("%s: %s", plan.Action, plan.Reason)
|
||||
}
|
||||
|
||||
var details catalogPlanDetails
|
||||
switch {
|
||||
case status.Catalog != nil:
|
||||
details, err = planExistingCatalog(ctx, req, *status.Catalog, outputs, workflow, scope, now)
|
||||
case status.SupersededLegacy != nil:
|
||||
details = planSupersededLegacy(req, outputs, workflow, scope, now)
|
||||
default:
|
||||
details, err = planWithoutCatalog(ctx, req, outputs, workflow, scope, now, status.HasContents)
|
||||
}
|
||||
plan.Action = details.Action
|
||||
plan.Reason = details.Reason
|
||||
plan.CatalogOutputsToWrite = details.CatalogOutputsToWrite
|
||||
plan.CatalogOutputsToRetain = details.CatalogOutputsToRetain
|
||||
plan.CatalogOutputsToDelete = details.CatalogOutputsToDelete
|
||||
plan.ClearDestinationRoot = details.ClearDestinationRoot
|
||||
if err != nil && req.Force && forceCanReplace(details.Action) {
|
||||
plan.Action = ActionForceReplace
|
||||
plan.Force = true
|
||||
plan.ClearDestinationRoot = true
|
||||
plan.CatalogOutputsToWrite = catalogOutputsForPlan(req, outputs, nil, scope, now)
|
||||
plan.CatalogOutputsToRetain = nil
|
||||
plan.CatalogOutputsToDelete = nil
|
||||
return plan, nil
|
||||
}
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -124,65 +180,202 @@ func validateRequest(req Request) error {
|
||||
if err := config.ValidatePublishTransformPolicy(req.Publish, req.Transform); err != nil {
|
||||
return fmt.Errorf("publish/transform policy: %w", err)
|
||||
}
|
||||
switch normalizeWorkflow(req.Workflow) {
|
||||
case config.WorkflowAdditive, config.WorkflowReplacement:
|
||||
default:
|
||||
return fmt.Errorf("destination.workflow must be %s or %s", config.WorkflowAdditive, config.WorkflowReplacement)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compareDestination(req Request, status state.DestinationStatus) state.Comparison {
|
||||
comparison := state.Compare(req.SourceBundle.Manifest, req.PipelineID, req.DestinationID, status)
|
||||
if req.PathMapping != config.PathMappingFixed || comparison.Outcome != state.OutcomeDifferentSourceConflict || status.State == nil {
|
||||
return comparison
|
||||
func normalizeWorkflow(workflow string) string {
|
||||
if workflow == "" {
|
||||
return config.WorkflowAdditive
|
||||
}
|
||||
destinationManifest := status.State.Source.Manifest
|
||||
if destinationManifest.Created.Before(req.SourceBundle.Manifest.Created) {
|
||||
return state.Comparison{Outcome: state.OutcomeDestinationOlder, Reason: "fixed destination source is older than selected source"}
|
||||
}
|
||||
if destinationManifest.Created.After(req.SourceBundle.Manifest.Created) {
|
||||
return state.Comparison{Outcome: state.OutcomeDestinationNewer, Reason: "fixed destination source is newer than selected source"}
|
||||
}
|
||||
return comparison
|
||||
return workflow
|
||||
}
|
||||
|
||||
func actionForComparison(comparison state.Comparison, transfer config.TransferPolicy, force bool) (Action, string) {
|
||||
switch comparison.Outcome {
|
||||
case state.OutcomeDestinationAbsent:
|
||||
return ActionPublishNew, comparison.Reason
|
||||
case state.OutcomeDestinationUnmanaged:
|
||||
if force {
|
||||
return ActionForceReplace, "forced replacement of unmanaged destination content"
|
||||
}
|
||||
return ActionFailUnmanaged, comparison.Reason
|
||||
case state.OutcomeInvalidState:
|
||||
return ActionFailConflict, comparison.Reason
|
||||
case state.OutcomeIdentityMismatch, state.OutcomeSameCreatedConflict, state.OutcomeDifferentSourceConflict:
|
||||
if transfer.OnConflict == config.TransferActionReplace {
|
||||
if force {
|
||||
return ActionForceReplace, "forced replacement of conflicting destination state: " + comparison.Reason
|
||||
}
|
||||
return ActionFailConflict, "destination conflict replacement requires --force"
|
||||
}
|
||||
return ActionFailConflict, comparison.Reason
|
||||
case state.OutcomeSameSource:
|
||||
if transfer.OnDestinationSame == config.TransferActionFail {
|
||||
return ActionFailConflict, "destination matches source and transfer policy requires failure"
|
||||
}
|
||||
return ActionSkipSame, comparison.Reason
|
||||
case state.OutcomeDestinationOlder:
|
||||
if transfer.OnDestinationOlder == config.TransferActionFail {
|
||||
return ActionFailConflict, "destination is older and transfer policy requires failure"
|
||||
}
|
||||
return ActionReplaceOlder, comparison.Reason
|
||||
case state.OutcomeDestinationNewer:
|
||||
if transfer.OnDestinationNewer == config.TransferActionReplace {
|
||||
if force {
|
||||
return ActionForceReplace, "forced replacement of newer destination state"
|
||||
}
|
||||
return ActionFailConflict, "destination is newer and replacement requires --force"
|
||||
}
|
||||
if transfer.OnDestinationNewer == config.TransferActionFail {
|
||||
return ActionFailConflict, "destination is newer and transfer policy requires failure"
|
||||
}
|
||||
return ActionSkipDestinationNewer, comparison.Reason
|
||||
default:
|
||||
return ActionFailConflict, "unsupported comparison outcome"
|
||||
func requestTime(req Request) time.Time {
|
||||
if req.Now.IsZero() {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return req.Now.UTC()
|
||||
}
|
||||
|
||||
func planExistingCatalog(ctx context.Context, req Request, catalog state.CatalogState, outputs []Output, workflow string, scope state.OwnerScope, now time.Time) (catalogPlanDetails, error) {
|
||||
if err := rejectCatalogUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, catalog.Outputs, outputs); err != nil {
|
||||
return catalogPlanDetails{
|
||||
Action: ActionFailUnmanaged,
|
||||
Reason: err.Error(),
|
||||
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
|
||||
}
|
||||
planned := outputPathSet(outputs)
|
||||
details := catalogPlanDetails{
|
||||
Action: actionForWorkflow(workflow),
|
||||
CatalogOutputsToWrite: catalogOutputsForPlan(req, outputs, catalog.Outputs, scope, now),
|
||||
}
|
||||
allPlannedOutputsMatch := catalogContainsMatchingOutputs(req, catalog.Outputs, outputs, scope)
|
||||
for _, output := range catalog.Outputs {
|
||||
if _, exists := planned[output.Path]; exists {
|
||||
continue
|
||||
}
|
||||
if workflow == config.WorkflowReplacement && output.PipelineID == scope.PipelineID && output.DestinationID == scope.DestinationID {
|
||||
details.CatalogOutputsToDelete = append(details.CatalogOutputsToDelete, output)
|
||||
continue
|
||||
}
|
||||
details.CatalogOutputsToRetain = append(details.CatalogOutputsToRetain, output)
|
||||
}
|
||||
if allPlannedOutputsMatch && (workflow == config.WorkflowAdditive || len(details.CatalogOutputsToDelete) == 0) {
|
||||
details.Action = ActionSkipSame
|
||||
details.CatalogOutputsToWrite = nil
|
||||
details.CatalogOutputsToDelete = nil
|
||||
}
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func catalogContainsMatchingOutputs(req Request, existing []state.CatalogOutputFile, outputs []Output, scope state.OwnerScope) bool {
|
||||
for _, output := range outputs {
|
||||
catalogOutput, ok := state.FindCatalogOutputByPath(existing, output.DestinationPath)
|
||||
if !ok || !catalogOutputMatchesPlan(req, catalogOutput, output, scope) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func catalogOutputMatchesPlan(req Request, catalogOutput state.CatalogOutputFile, output Output, scope state.OwnerScope) bool {
|
||||
if catalogOutput.PipelineID != scope.PipelineID ||
|
||||
catalogOutput.DestinationID != scope.DestinationID ||
|
||||
catalogOutput.Source.ID != req.SourceBundle.Manifest.ID ||
|
||||
catalogOutput.Source.Digest != req.SourceBundle.Manifest.Digest ||
|
||||
!catalogOutput.Source.Created.Equal(req.SourceBundle.Manifest.Created) ||
|
||||
catalogOutput.Path != output.DestinationPath ||
|
||||
catalogOutput.Kind != output.Kind ||
|
||||
catalogOutput.URL != output.URL ||
|
||||
catalogOutput.SHA256 != output.SHA256 ||
|
||||
catalogOutput.Size != output.Size {
|
||||
return false
|
||||
}
|
||||
if output.Kind == state.OutputKindGenerated {
|
||||
return catalogOutput.SourcePath == output.SourcePath && catalogOutput.Transform == output.Transform
|
||||
}
|
||||
return catalogOutput.SourcePath == "" && catalogOutput.Transform == ""
|
||||
}
|
||||
|
||||
func planSupersededLegacy(req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time) catalogPlanDetails {
|
||||
details := catalogPlanDetails{
|
||||
Action: actionForWorkflow(workflow),
|
||||
CatalogOutputsToWrite: catalogOutputsForPlan(req, outputs, nil, scope, now),
|
||||
}
|
||||
if workflow == config.WorkflowReplacement {
|
||||
details.ClearDestinationRoot = true
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func planWithoutCatalog(ctx context.Context, req Request, outputs []Output, workflow string, scope state.OwnerScope, now time.Time, hasContents bool) (catalogPlanDetails, error) {
|
||||
if hasContents {
|
||||
err := fmt.Errorf("destination has content but no distributor state")
|
||||
return catalogPlanDetails{
|
||||
Action: ActionFailUnmanaged,
|
||||
Reason: err.Error(),
|
||||
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
|
||||
}
|
||||
if err := rejectCatalogUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, nil, outputs); err != nil {
|
||||
return catalogPlanDetails{
|
||||
Action: ActionFailUnmanaged,
|
||||
Reason: err.Error(),
|
||||
}, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
|
||||
}
|
||||
return catalogPlanDetails{
|
||||
Action: ActionPublishNew,
|
||||
CatalogOutputsToWrite: catalogOutputsForPlan(req, outputs, nil, scope, now),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func forceCanReplace(action Action) bool {
|
||||
return action == ActionFailUnmanaged || action == ActionFailConflict
|
||||
}
|
||||
|
||||
func actionForWorkflow(workflow string) Action {
|
||||
if workflow == config.WorkflowReplacement {
|
||||
return ActionReplaceCatalog
|
||||
}
|
||||
return ActionUpsertAdditive
|
||||
}
|
||||
|
||||
func catalogOutputsForPlan(req Request, outputs []Output, existing []state.CatalogOutputFile, scope state.OwnerScope, now time.Time) []state.CatalogOutputFile {
|
||||
files := make([]state.CatalogOutputFile, 0, len(outputs))
|
||||
source := state.CatalogSourceIdentity{
|
||||
ID: req.SourceBundle.Manifest.ID,
|
||||
Digest: req.SourceBundle.Manifest.Digest,
|
||||
Created: req.SourceBundle.Manifest.Created,
|
||||
}
|
||||
for _, output := range outputs {
|
||||
createdAt := now
|
||||
if existingOutput, ok := state.FindCatalogOutputByPath(existing, output.DestinationPath); ok {
|
||||
createdAt = existingOutput.CreatedAt
|
||||
}
|
||||
file := state.CatalogOutputFile{
|
||||
Path: output.DestinationPath,
|
||||
PipelineID: scope.PipelineID,
|
||||
DestinationID: scope.DestinationID,
|
||||
Source: source,
|
||||
Kind: output.Kind,
|
||||
URL: output.URL,
|
||||
SHA256: output.SHA256,
|
||||
Size: output.Size,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if output.Kind == state.OutputKindGenerated {
|
||||
file.SourcePath = output.SourcePath
|
||||
file.Transform = output.Transform
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func rejectCatalogUnmanagedCollisions(ctx context.Context, backend storage.Backend, bundlePath string, existing []state.CatalogOutputFile, outputs []Output) error {
|
||||
managed := catalogOutputPathSet(existing)
|
||||
for _, output := range outputs {
|
||||
if _, exists := managed[output.DestinationPath]; exists {
|
||||
continue
|
||||
}
|
||||
destinationPath, err := storage.Join(bundlePath, output.DestinationPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := backend.Stat(ctx, destinationPath); err == nil {
|
||||
return fmt.Errorf("destination output path %s exists but is not managed by catalog state", storage.DisplayPath(output.DestinationPath))
|
||||
} else if !storage.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputPaths(outputs []Output) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.DestinationPath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func outputPathSet(outputs []Output) map[string]struct{} {
|
||||
paths := make(map[string]struct{}, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths[output.DestinationPath] = struct{}{}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func catalogOutputPathSet(outputs []state.CatalogOutputFile) map[string]struct{} {
|
||||
paths := make(map[string]struct{}, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths[output.Path] = struct{}{}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user