50 Commits
v0.4.0 ... main

Author SHA1 Message Date
8366af6fb6 Close completed catalog roadmap
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-06-19 17:15:24 +00:00
5e47d89355 Remove legacy publish and state paths 2026-06-19 17:12:21 +00:00
2e0d903626 Implement catalog idempotent publish skips 2026-06-19 16:57:38 +00:00
484fda2514 Synchronize catalog documentation and examples 2026-06-19 16:50:40 +00:00
9a2eaf8e5e Implement catalog force replacement 2026-06-19 16:39:01 +00:00
4e673dda76 Update the staged implementation plan to address remaining items from the destination policy refactor 2026-06-19 11:25:36 -05:00
cf7e62733e Support catalog maintenance commands 2026-06-19 15:58:36 +00:00
91b72478d5 Update run reporting for catalog workflows 2026-06-19 15:50:09 +00:00
52078e2195 Write catalog state during publish 2026-06-19 15:44:23 +00:00
77cde40296 Add catalog workflow planning 2026-06-19 15:36:26 +00:00
20129bfff5 Add catalog destination state schema 2026-06-19 15:28:03 +00:00
a95662226f Add destination workflow config 2026-06-19 15:18:14 +00:00
d23c624179 Added a roadmap and implementation plan for a significant refactor of pipeline destination policy and catalog state 2026-06-19 10:10:38 -05:00
69043801d0 Update policy for replacement of managed files 2026-06-18 13:38:44 -05:00
8b0ce4d134 Add takeover backend regressions and close roadmap 2026-06-18 15:40:24 +00:00
eba65018be Expose takeover actions in run output 2026-06-18 15:35:06 +00:00
11d1eabe2a Implement shared-root takeover policy 2026-06-18 15:27:50 +00:00
c02106987f Implement single-owner managed takeover 2026-06-18 15:18:38 +00:00
598b665307 Add structured destination comparison details 2026-06-18 15:09:18 +00:00
c4e8ebff6f Add destination takeover config policy 2026-06-18 15:05:09 +00:00
3da7f931b2 Add a feature roadmap and staged implementation plan for configurable levels of managed file overwrite protection 2026-06-18 10:01:20 -05:00
b10a8bd194 Add support for CSS links in generated HTML outputs 2026-06-13 22:16:15 -05:00
c84d8868d1 Fix a bug in the SFTP backend that would cause an error when overwriting existing files 2026-06-13 13:57:50 -05:00
fc33bbca54 Update documentation policy 2026-06-13 13:55:59 -05:00
e3b92a3b5d Consolidate future work into docs/roadmap/future.md and remove completed roadmap docs
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-06-09 08:42:27 -05:00
c804fd604a Refresh feature roadmap documentation 2026-06-08 19:42:08 +00:00
ea562c1c3a Expose managed output pruning 2026-06-08 19:38:49 +00:00
6daddad543 Add managed output pruning 2026-06-08 19:32:57 +00:00
c67ecf86a9 Add prune retention planning 2026-06-08 19:26:48 +00:00
2abd09bde3 Expose reconcile state command 2026-06-08 19:19:43 +00:00
de6723c5de Add reconcile state planning core 2026-06-08 19:14:44 +00:00
cb9502f790 Document shared-root publishing 2026-06-08 19:06:23 +00:00
9afb3550c4 Implement shared-root publish execution 2026-06-08 19:02:12 +00:00
89169f810f Add shared-root publish planning 2026-06-08 18:53:22 +00:00
eb86cf9ab6 Add shared-root destination state model 2026-06-08 18:45:16 +00:00
ef0b6c1056 Document reconciliation modes 2026-06-08 18:36:07 +00:00
93821ea6f9 Implement single-owner reconciliation modes 2026-06-08 18:30:52 +00:00
b7db3993fb Add reconciliation state foundation 2026-06-08 18:24:12 +00:00
c04432e40b Revise and solidify the implementation roadmap 2026-06-08 13:15:46 -05:00
8f3ef33f18 Added four new roadmaps related to state management and a corresponding implementation plan 2026-06-08 13:09:00 -05:00
ee6a351960 Clean up upload API documentation roadmap
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-06-08 04:48:06 +00:00
29f01da37b Document pipeline-scoped upload behavior 2026-06-08 04:46:22 +00:00
bd5892d1f2 Add upload pipeline integration coverage 2026-06-08 04:41:05 +00:00
ce43a6044a Route upload client by pipeline 2026-06-08 04:37:33 +00:00
1c5d7198e3 Scope upload idempotency by token 2026-06-08 04:34:29 +00:00
9d4694c6d8 Route uploads by pipeline path 2026-06-08 04:32:20 +00:00
033b2e5015 Add upload token config validation 2026-06-08 04:27:49 +00:00
4fa7d1ebb5 Add roadmap and an implementation plan for HTTP API upgrades 2026-06-07 23:19:26 -05:00
f98e528c90 Update documentation to clarity bundle_id and idempotency_key usage and distinctions 2026-06-07 20:45:29 -05:00
25fbfc4677 Update documentation relating to the public packages and http_upload API 2026-06-07 13:33:27 -05:00
94 changed files with 7800 additions and 2333 deletions

View File

@@ -10,11 +10,12 @@ Run the maintained local example:
go run ./cmd/distributor run --config examples/local-publish.yml go run ./cmd/distributor run --config examples/local-publish.yml
``` ```
Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to build, write, parse, and validate local source bundles with the same manifest contract used by the CLI. They can use `gitea.maximumdirect.net/eric/distributor/pkg/upload` to build or validate a bundle and submit it to `distributor serve` with bearer authentication and idempotency keys. See [Source bundle contract](docs/integrations/source-bundle.md) and [HTTP upload contract](docs/integrations/http-upload.md). 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) - [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md) - [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md) - [Operations guide](docs/operations.md)
- [Consumer API guide](docs/consumers/api.md)
- [Troubleshooting](docs/troubleshooting.md) - [Troubleshooting](docs/troubleshooting.md)
- [Integration contracts](docs/integrations/source-bundle.md) - [Integration contracts](docs/integrations/source-bundle.md)
- [Development architecture](docs/policy/architecture.md) - [Development architecture](docs/policy/architecture.md)

View File

@@ -21,6 +21,8 @@ distributor [--help]
distributor help distributor help
distributor version [--format text|json] distributor version [--format text|json]
distributor run [--config <path>] [--dry-run] [--force] [--format text|json] distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
distributor 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 serve [--config <path>]
distributor validate [--format text|json] <path> distributor validate [--format text|json] <path>
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json] distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
@@ -33,6 +35,8 @@ distributor manifest create --id <bundle-id> [options] <bundle-path>
- `version` prints the application name and version. - `version` prints the application name and version.
- `run` executes configured pipelines against their destinations. - `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. - `serve` starts the authenticated HTTP upload API defined by the configuration file.
- `validate` checks a local bundle path or a configured source bundle. - `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. - `inspect` reports manifest and file metadata for a local bundle path or a configured source bundle.
@@ -46,7 +50,7 @@ distributor manifest create --id <bundle-id> [options] <bundle-path>
### Common Output Format ### Common Output Format
`--format text|json` is supported by `version`, `run`, `validate`, `inspect`, and `manifest create`. `--format text|json` is supported by `version`, `run`, `reconcile-state`, `prune`, `validate`, `inspect`, and `manifest create`.
- `text` is the default human-readable output. - `text` is the default human-readable output.
- `json` emits one JSON document for successful command execution. - `json` emits one JSON document for successful command execution.
@@ -68,11 +72,41 @@ distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
- `--config <path>` loads the pipeline configuration. If omitted, the application 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. - `--dry-run` validates inputs and reports destination actions without applying changes.
- `--force` permits a run when destination state indicates a conservative safety check would otherwise block it. - `--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. - `--format text|json` selects human-readable or machine-readable output.
`run` accepts no positional arguments. `run` accepts no positional arguments.
### `reconcile-state`
```sh
distributor reconcile-state --config <path> --pipeline <id> --destination <id> [--all-owners] [--dry-run] [--format text|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.
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.
### `prune`
```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` ### `serve`
```sh ```sh
@@ -172,7 +206,54 @@ go run ./cmd/distributor run --config examples/local-publish.yml --dry-run
go run ./cmd/distributor run --config examples/local-publish.yml go run ./cmd/distributor run --config examples/local-publish.yml
``` ```
Use `--format json` when automation needs structured run results. Use `--force` only when the operator has reviewed the destination state conflict and intentionally wants to continue. 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 reconcile-state \
--config examples/local-publish.yml \
--pipeline example-source-bundle \
--destination local-archive \
--dry-run
```
Apply the repair after reviewing the report:
```sh
go run ./cmd/distributor reconcile-state \
--config examples/local-publish.yml \
--pipeline example-source-bundle \
--destination local-archive
```
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
go run ./cmd/distributor prune \
--config examples/local-publish.yml \
--pipeline example-source-bundle \
--destination local-archive \
--dry-run
```
Apply after reviewing the report:
```sh
go run ./cmd/distributor prune \
--config examples/local-publish.yml \
--pipeline example-source-bundle \
--destination local-archive \
--apply
```
Use `--format json` when automation needs structured prune results.
### Run HTML And Fan-Out Examples ### Run HTML And Fan-Out Examples
@@ -211,12 +292,16 @@ Text output is optimized for direct operator use. JSON output is optimized for a
- Usage errors and fatal setup errors exit non-zero and do not emit a JSON result document. - 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. - `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. - 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.
## Diagnostics And Recovery ## Diagnostics And Recovery
- Use `validate` before `run` when checking a bundle supplied by another process. - 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 `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 `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 [Configuration](config.md) for schema and default details.
- Use [Troubleshooting](troubleshooting.md) for common errors and corrective action. - Use [Troubleshooting](troubleshooting.md) for common errors and corrective action.
- Use [Operations](operations.md) for HTTP upload operation, state files, and recovery workflows. - Use [Operations](operations.md) for HTTP upload operation, state files, and recovery workflows.

View File

@@ -2,11 +2,11 @@
Audience: administrators, operators, and advanced users who write YAML configuration for `distributor`. Audience: administrators, operators, and advanced users who write YAML configuration for `distributor`.
This document is the canonical user-facing configuration reference. CLI syntax lives in [CLI](cli.md), operating procedures live in [Operations](operations.md), symptom-oriented recovery lives in [Troubleshooting](troubleshooting.md), and external contracts live under [Integrations](integrations/source-bundle.md). 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).
## Config File Loading ## Config File Loading
`distributor run --config <path>` and `distributor serve --config <path>` load the YAML file at `<path>`. If `--config` is omitted, both commands use: `distributor run --config <path>` and `distributor serve --config <path>` load the YAML file at `<path>`. If `--config` is omitted, commands use:
```text ```text
/usr/local/etc/distributor/config.yml /usr/local/etc/distributor/config.yml
@@ -17,11 +17,11 @@ YAML decoding rejects unknown fields. Defaults are applied after decoding and be
Runtime backend support is command-specific: Runtime backend support is command-specific:
- `run`, `validate --config`, and `inspect --config` execute `local`, `ssh`, and `s3` sources. - `run`, `validate --config`, and `inspect --config` execute `local`, `ssh`, and `s3` sources.
- `run` executes `local`, `ssh`, and `s3` destinations. - `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. - `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. - `http_upload` is valid only as a source backend.
## Minimal Local Config ## Minimal Working Config
```yaml ```yaml
pipelines: pipelines:
@@ -35,9 +35,9 @@ pipelines:
path: /srv/reports/archive path: /srv/reports/archive
``` ```
This config publishes source files only. It uses default validation, destination path mapping, publish, transfer, and HTTP server values. 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 ```yaml
server: server:
@@ -61,21 +61,20 @@ pipelines:
- id: archive - id: archive
backend: local backend: local
path: /srv/reports/archive path: /srv/reports/archive
workflow: additive
publish: publish:
source: true source: true
html: false html: false
path_mapping: path_mapping:
mode: preserve_relative mode: preserve_relative
transfer: retention:
on_destination_same: skip prune:
on_destination_older: replace enabled: false
on_destination_newer: skip
on_conflict: fail
``` ```
## HTTP Upload Source Config ## HTTP Upload Source Config
HTTP upload sources are configured on pipelines and are served by `distributor serve`. Upload tokens are resolved from the process environment or `secrets.directory`; literal bearer tokens are not configured in YAML. 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 ```yaml
server: server:
@@ -86,22 +85,24 @@ server:
queue_size: 16 queue_size: 16
max_concurrency: 1 max_concurrency: 1
retention: 24h retention: 24h
upload_tokens:
- id: weather-reporter
token_env: WEATHER_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
pipelines: pipelines:
- id: weather-daily - id: weather-daily
source: source:
backend: http_upload backend: http_upload
token_env: WEATHER_DAILY_UPLOAD_TOKEN
staging_path: /var/spool/distributor/weather-daily
max_upload_size: 20MB
destinations: destinations:
- id: archive - id: archive
backend: local backend: local
path: /srv/reports/archive path: /srv/reports/archive
``` ```
`token_env` is required for `http_upload` sources. `staging_path` defaults to `<server.http.staging_root>/<pipeline id>`. `max_upload_size` defaults to `server.http.max_upload_size`. `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.
`serve` maps each resolved bearer token to exactly one `http_upload` pipeline. Startup fails when a token is missing, empty, or duplicates another upload pipeline token. For `http_upload` sources, `staging_path` defaults to `<server.http.staging_root>/<pipeline id>`. `max_upload_size` defaults to `server.http.max_upload_size`.
## Top-Level Fields ## Top-Level Fields
@@ -124,18 +125,30 @@ Numeric server values and durations must be greater than zero after defaults are
See [Secrets](#secrets) for resolution rules. 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`
`pipelines` is required and must contain at least one pipeline. `pipelines` is required and must contain at least one pipeline.
Each pipeline has: Each pipeline has:
- `id`: required unique slug-like identifier. It must start with a letter or number and may contain letters, numbers, `.`, `_`, and `-`. - `id`: required unique slug-like identifier.
- `source`: required source backend config. - `source`: required source backend config.
- `validation`: optional validation policy. - `validation`: optional validation policy.
- `destinations`: required non-empty destination list. - `destinations`: required non-empty destination list.
Pipeline ids must be unique across the config. Slug-like identifiers must start with a letter or number and may contain letters, numbers, `.`, `_`, and `-`.
## Backend Reference ## Backend Reference
@@ -153,7 +166,7 @@ path: /srv/distributor/archive
### SSH/SFTP Backend ### SSH/SFTP Backend
SSH backends use native SFTP and can be used as sources and destinations. Adapter protocol behavior is documented in [SSH/SFTP Integration](integrations/ssh-sftp.md). 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).
```yaml ```yaml
backend: ssh backend: ssh
@@ -175,17 +188,11 @@ host_key_policy: strict
- `known_hosts`: optional OpenSSH `known_hosts` path. - `known_hosts`: optional OpenSSH `known_hosts` path.
- `host_key_policy`: optional host key policy. Default: `accept-new`. - `host_key_policy`: optional host key policy. Default: `accept-new`.
Accepted host key policy values: 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.
- `strict` or boolean `true`: require a matching known host key.
- `accept-new`: accept and persist a new host key, but reject changed known keys.
- `off` or boolean `false`: disable host key checking.
Authentication uses SSH agent identities when `SSH_AUTH_SOCK` is available, then `ssh_key_file` when configured. Password authentication is not configured in YAML.
### S3-Compatible Backend ### S3-Compatible Backend
S3 backends can be used as sources and destinations. Adapter protocol behavior is documented in [S3-Compatible Storage Integration](integrations/s3.md). S3 backends can be used as sources and destinations. Adapter behavior is documented in [S3-Compatible Storage Integration](integrations/s3.md).
```yaml ```yaml
backend: s3 backend: s3
@@ -216,13 +223,11 @@ HTTP upload backends are valid only as pipeline sources and are served by `distr
```yaml ```yaml
backend: http_upload backend: http_upload
token_env: WEATHER_DAILY_UPLOAD_TOKEN
staging_path: /var/spool/distributor/weather-daily staging_path: /var/spool/distributor/weather-daily
max_upload_size: 20MB max_upload_size: 20MB
``` ```
- `backend`: required value `http_upload`. - `backend`: required value `http_upload`.
- `token_env`: required environment variable or secret-file name containing the bearer token.
- `staging_path`: optional staging path. Default: `<server.http.staging_root>/<pipeline id>`. - `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`. - `max_upload_size`: optional per-source upload limit. Default: `server.http.max_upload_size`.
@@ -239,35 +244,50 @@ Source bundle digest mismatches fail validation before destination writes occur.
## Destination Fields ## Destination Fields
Each destination embeds a backend config at the destination level and may also configure publishing, transforms, path mapping, links, and transfer behavior. Each destination embeds a backend config at the destination level and may also configure workflow, publishing, transforms, path mapping, links, and retention.
```yaml ```yaml
destinations: destinations:
- id: archive - id: archive
backend: local backend: local
path: /srv/reports/archive path: /srv/reports/archive
workflow: additive
publish: publish:
source: true source: true
html: false html: false
path_mapping: path_mapping:
mode: preserve_relative mode: preserve_relative
transfer:
on_destination_same: skip
on_destination_older: replace
on_destination_newer: skip
on_conflict: fail
``` ```
- `id`: required unique slug-like identifier within the pipeline. - `id`: required unique slug-like identifier within the pipeline.
- Backend fields: required according to the selected destination backend. - Backend fields: required according to the selected destination backend.
- `workflow`: optional catalog update workflow. Default: `additive`.
- `publish`: optional publish policy. Default: source-only publication. - `publish`: optional publish policy. Default: source-only publication.
- `transform`: required only when publishing generated HTML. - `transform`: required only when publishing generated HTML.
- `path_mapping`: optional destination path mapping policy. - `path_mapping`: optional destination path mapping policy.
- `links`: optional public URL metadata policy. - `links`: optional public URL metadata policy.
- `transfer`: optional destination reconciliation policy. - `retention`: optional managed-output retention policy.
Destination ids must be unique within a pipeline. 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 ## Publish And Transform Policy
### Source-Only Publication ### Source-Only Publication
@@ -290,6 +310,7 @@ transform:
markdown_to_html: markdown_to_html:
enabled: true enabled: true
mode: sidecar mode: sidecar
css_href: /assets/report.css
``` ```
`publish.html` controls whether generated HTML outputs are published. When `publish.html` is `true`, `transform.markdown_to_html.enabled` must also be `true`. `publish.html` controls whether generated HTML outputs are published. When `publish.html` is `true`, `transform.markdown_to_html.enabled` must also be `true`.
@@ -299,10 +320,13 @@ Markdown transform fields:
- `transform.markdown_to_html.enabled`: enables Markdown-to-HTML generation for this destination. - `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.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.input`: optional source manifest path for `index` mode only.
- `transform.markdown_to_html.css_href`: optional stylesheet href to link from generated HTML.
`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. `sidecar` mode renders every manifest-listed `.md` file to a same-directory `.html` output. `index` mode renders one Markdown source to `index.html` at the destination bundle path. If `index` mode omits `input`, the selected source bundle must contain exactly one Markdown file.
At least one output type must be enabled. Enabled Markdown transforms are rejected when `publish.html` is `false`, and `input` is rejected unless `mode` is `index`. `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 ## Destination Path Mapping
@@ -338,26 +362,27 @@ Primary URL policies:
- `html`: use the first generated HTML output. - `html`: use the first generated HTML output.
- `source`: use the first copied source output. - `source`: use the first copied source output.
If no output matches the primary policy, per-output URLs may still be 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.
## Transfer Policy ## Retention Policy
```yaml ```yaml
transfer: retention:
on_destination_same: skip prune:
on_destination_older: replace enabled: false
on_destination_newer: skip older_than: 168h
on_conflict: fail keep_latest: 3
``` ```
Transfer fields and accepted values: - `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.
- `transfer.on_destination_same`: `skip` or `fail`. Default: `skip`. 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.
- `transfer.on_destination_older`: `replace` or `fail`. Default: `replace`.
- `transfer.on_destination_newer`: `skip`, `replace`, or `fail`. Default: `skip`.
- `transfer.on_conflict`: `fail` or `replace`. Default: `fail`.
`replace` for `on_destination_newer` and `on_conflict` is honored only when `run --force` is supplied. There is no config field that enables forced replacement by default. 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.
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 ## Size And Duration Values
@@ -390,14 +415,12 @@ Defaults are applied after YAML decoding and before validation:
- S3 `force_path_style: true` - S3 `force_path_style: true`
- `http_upload` source `staging_path: <server.http.staging_root>/<pipeline id>` - `http_upload` source `staging_path: <server.http.staging_root>/<pipeline id>`
- `http_upload` source `max_upload_size: server.http.max_upload_size` - `http_upload` source `max_upload_size: server.http.max_upload_size`
- `workflow: additive`
- `publish.source: true` and `publish.html: false` - `publish.source: true` and `publish.html: false`
- `transform.markdown_to_html.mode: sidecar` when a Markdown transform block is present and mode is omitted - `transform.markdown_to_html.mode: sidecar` when a Markdown transform block is present and mode is omitted
- `path_mapping.mode: preserve_relative` - `path_mapping.mode: preserve_relative`
- `links.primary: auto` when a `links` block is present and `primary` is omitted - `links.primary: auto` when a `links` block is present and `primary` is omitted
- `transfer.on_destination_same: skip` - `retention.prune.enabled: false`
- `transfer.on_destination_older: replace`
- `transfer.on_destination_newer: skip`
- `transfer.on_conflict: fail`
## Secrets ## Secrets
@@ -416,11 +439,11 @@ Fields resolved through this resolver:
- `credentials.access_key_id_env` - `credentials.access_key_id_env`
- `credentials.secret_access_key_env` - `credentials.secret_access_key_env`
- `source.token_env` for `http_upload` sources - `upload_tokens[].token_env`
## Maintained Examples ## Maintained Examples
Maintained examples live under [examples](../examples/). Config tests load every file listed here. Maintained examples live under [examples](../examples/). Config tests load these YAML files.
Local examples: Local examples:
@@ -430,6 +453,8 @@ Local examples:
- `local-index.yml`: local `index.html` publication. - `local-index.yml`: local `index.html` publication.
- `fan-out.yml`: local fan-out publication to source and HTML destinations. - `fan-out.yml`: local fan-out publication to source and HTML destinations.
- `archive-and-latest.yml`: local archive plus fixed latest publication. - `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`. - `http-upload-local.yml`: local HTTP upload server config; requires `DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN` in the process environment or as a secret-file name before running `serve`.
Environment-gated remote examples: Environment-gated remote examples:

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

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
Audience: producers, operators, and maintainers integrating with `distributor serve`. 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`. Each bearer token maps to exactly one configured pipeline. `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 ## Authentication
@@ -12,9 +12,9 @@ Uploads authenticate with:
Authorization: Bearer <token> Authorization: Bearer <token>
``` ```
Token values are resolved from the configured `source.token_env` through the process environment or `secrets.directory`. Tokens are not configured as YAML literal values. 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. The bearer token selects the pipeline. Requests that include `pipeline` or `pipeline_id` query parameters are rejected. Use the pipeline id in the upload path.
## Endpoints ## Endpoints
@@ -26,7 +26,7 @@ Returns `200 OK` when the server is running:
{"status":"ok"} {"status":"ok"}
``` ```
### `POST /upload` ### `POST /v1/pipelines/{pipeline_id}/upload`
Accepts one source bundle archive and returns after the archive is staged and validated. Accepts one source bundle archive and returns after the archive is staged and validated.
@@ -36,7 +36,7 @@ Producers may include:
Idempotency-Key: <key> Idempotency-Key: <key>
``` ```
Idempotency keys are scoped to the authenticated pipeline selected by the bearer token. Valid keys are non-empty ASCII strings up to 128 bytes using letters, digits, `.`, `_`, `-`, and `:`. Invalid keys return `400`. `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: Accepted content types:
@@ -54,6 +54,8 @@ Common error responses:
- `400`: pipeline query supplied, invalid idempotency key, archive rejected, malformed archive, or invalid staged source bundle. - `400`: pipeline query supplied, invalid idempotency key, archive rejected, malformed archive, or invalid staged source bundle.
- `401`: missing, empty, or unknown bearer token. - `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. - `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. - `413`: upload body exceeds the selected pipeline size limit.
- `415`: unsupported content type. - `415`: unsupported content type.
@@ -71,7 +73,7 @@ Retryable idempotency conflicts include:
{"error":"upload idempotency key is already being processed","retryable":true} {"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 authenticated pipeline and the same 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 returns `409 Conflict`. 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>` ### `GET /runs/<run-id>`
@@ -108,7 +110,7 @@ The uploaded archive size and extracted bundle size are bounded by the selected
## Go Producer Helper ## 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: 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 ```go
client, err := upload.NewClient(upload.ClientOptions{ client, err := upload.NewClient(upload.ClientOptions{
@@ -119,14 +121,15 @@ if err != nil {
return err return err
} }
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{ result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
PipelineID: "reports",
Root: "examples/source-bundle", Root: "examples/source-bundle",
IdempotencyKey: "reports.example.20260604T120000Z", IdempotencyKey: "reports.example.20260604T120000Z",
}) })
``` ```
`Endpoint` is the server base URL; the package derives `/upload` and `/runs/<run-id>`. `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. `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`, `409`, `413`, or `415`. Bearer token values are redacted from returned errors. 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 ## Queue And Retention
@@ -138,7 +141,7 @@ Idempotency records are memory-only, expire with completed upload status records
## Boundaries ## Boundaries
The HTTP API does not expose pipeline selection by request parameter, TLS, public routing policy, or durable status storage. Put public access controls, TLS termination, and rate limiting in deployment infrastructure. 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 ## Tests

View File

@@ -10,7 +10,7 @@ Rendering uses `github.com/yuin/goldmark`. The exact version is pinned in `go.mo
## Renderer Behavior ## Renderer Behavior
The transformer constructs `goldmark.New()` with no project-specific extensions, parser options, renderer options, templates, CSS, or metadata injection. The transformer constructs `goldmark.New()` with no project-specific extensions, parser options, renderer options, templates, or source manifest metadata injection.
Supported output modes: Supported output modes:
@@ -19,6 +19,8 @@ Supported output modes:
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`. 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. 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.
## HTML Wrapper ## HTML Wrapper
@@ -28,10 +30,11 @@ Rendered Markdown body HTML is wrapped in a fixed document shell:
- `<!doctype html>` - `<!doctype html>`
- `<html lang="en">` - `<html lang="en">`
- UTF-8 `<meta charset>` - UTF-8 `<meta charset>`
- optional `<link rel="stylesheet" href="...">` when `css_href` is configured
- empty `<title>` - empty `<title>`
- `<body>` containing the rendered Markdown body - `<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
@@ -47,7 +50,7 @@ Destination state stores generated outputs with `kind: generated`, `source_path`
## Boundaries ## Boundaries
Markdown rendering does not mutate source bundles, publish files, write `.distributor.json`, select destination actions, or choose transfer policy. Publish planning decides whether generated HTML is selected for a destination and destination state records the generated output metadata. 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 ## Tests

View File

@@ -52,7 +52,7 @@ The adapter uses these S3 operations:
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. Writes buffer the input and set `ContentLength`. If no content type is supplied by the caller, the adapter infers a content type from the logical path.
Normal replacement and failed-write cleanup delete only managed output objects plus `.distributor.json`. Forced replacement deletes objects under the bounded destination bundle prefix. The backend does not manage bucket versioning, lifecycle rules, object lock, or delete markers. 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 ## Error Mapping

View File

@@ -27,7 +27,7 @@ Current schema version: `1`.
Required manifest fields: Required manifest fields:
- `schema_version`: must be `1`. - `schema_version`: must be `1`.
- `id`: non-empty bundle identifier. - `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. - `digest`: lowercase `sha256:<64 hex>` digest of the ordered `files` list.
- `created`: RFC3339 timestamp. - `created`: RFC3339 timestamp.
- `files`: non-empty ordered list of file records. - `files`: non-empty ordered list of file records.
@@ -60,7 +60,7 @@ File order is significant. Explicit file lists preserve caller order. Scan mode
## Producer APIs ## Producer APIs
Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to build and validate this contract: 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. - `BuildManifest`: builds a manifest from explicit file paths or scan mode.
- `WriteManifest`: writes `manifest.json`, optionally replacing an existing manifest. - `WriteManifest`: writes `manifest.json`, optionally replacing an existing manifest.
@@ -68,7 +68,7 @@ Go producers can use `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to bu
- `LoadManifest`, `ParseManifest`, `ValidateManifest`, and `ValidateBundle`: parse and validate local bundles. - `LoadManifest`, `ParseManifest`, `ValidateManifest`, and `ValidateBundle`: parse and validate local bundles.
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers. - `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers.
Go producers that submit bundles to `distributor serve` can use `gitea.maximumdirect.net/eric/distributor/pkg/upload`. It builds on `pkg/bundle`, packages valid bundles as gzip-compressed tar uploads, sends bearer authentication, and includes idempotency keys for safe retry behavior. See [HTTP Upload API Contract](http-upload.md). 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: CLI producers can use:

View File

@@ -54,7 +54,7 @@ The configured `path` is the backend root. All source discovery, destination pat
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. 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. 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 ## Boundaries

View File

@@ -4,23 +4,23 @@ Audience: developers and LLM coding agents changing `internal/app`.
## Purpose ## Purpose
`internal/app` owns top-level application use cases: run, single-pipeline run, staged-source run, validate, inspect, manifest creation, and HTTP upload serving. It coordinates config loading, secret resolution, backend construction, source discovery, destination selection, publish planning/execution, notification handoff, output projection, and upload coordination. `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.
## Inputs And Outputs ## Inputs And Outputs
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. 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.
Outputs include `RunReport`, validate/inspect/manifest results, CLI text/JSON projections, HTTP upload responses, upload status records, and errors. Destination-scoped failures can return a partial run report plus an aggregated error; fatal setup failures return before a complete report exists. 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.
## Boundaries ## Boundaries
`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. `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.
User-facing command parsing stays in `internal/cli`. User-facing config reference stays in `docs/config.md`. External contracts live under `docs/integrations/`. 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/`.
## Config Fields Used ## Config Fields Used
The package consumes the loaded `config.Config`: `server.http`, `secrets.directory`, pipeline ids, source and destination backend fields, validation policy, publish policy, transform policy, path mapping, links, and transfer policy. 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.
Config fields are validated and defaulted by `internal/config` before app workflows use them. Config fields are validated and defaulted by `internal/config` before app workflows use them.
@@ -32,15 +32,21 @@ The app layer registers default transforms, including Markdown-to-HTML, and supp
## State And Manifest Behavior ## State And Manifest Behavior
Run workflows discover and validate source bundles through `internal/bundle`. Destination state actions are prepared and written through `internal/publish` and `internal/state`; the app layer records report projections of those actions and results. 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.
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.
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.
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.
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. HTTP uploads stage and validate archives before enqueueing a pipeline run with a local staged source root. Go producers can use the public `pkg/upload` package to create client-side gzip tar uploads for this server contract; `internal/app` remains the server-side orchestration boundary and does not import that producer package.
Upload idempotency is owned by the upload coordinator. Optional `Idempotency-Key` values are scoped to the authenticated pipeline. 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 key and same manifest, and rejects the same key with a different manifest as a conflict. Upload idempotency is owned by the upload coordinator. Optional `Idempotency-Key` values are scoped to token id, pipeline id, and key. The coordinator reserves a key while staging is in progress, records the accepted run id with the validated source manifest identity after staging succeeds, returns the original accepted record for the same scoped key and same manifest, and rejects the same scoped key with a different manifest as a conflict.
## Skip And Resume Behavior ## Skip And Resume Behavior
Fan-out destinations are independent. A destination failure is recorded and does not prevent later destinations from being attempted. Dry-run builds plans and reports without destination writes, destination state writes, notifier calls, or SSH known-host persistence. 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.
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. 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.
@@ -48,11 +54,18 @@ HTTP upload status is in memory. Accepted jobs move through accepted, queued, ru
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. 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.
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.
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.
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. HTTP upload startup fails if upload tokens are missing, empty, or duplicated. Upload requests can fail during authentication, idempotency-key validation, content-type validation, idempotency conflict checks, queue admission, archive staging, source validation, or later publish execution.
## Tests To Inspect ## Tests To Inspect
- `internal/app/*_test.go` - `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/cli/root_test.go`
- `internal/config/*_test.go` - `internal/config/*_test.go`
- `internal/ingest/*_test.go` - `internal/ingest/*_test.go`
@@ -67,3 +80,5 @@ HTTP upload startup fails if upload tokens are missing, empty, or duplicated. Up
- Upload admission stages and validates a bundle before returning a run id. - Upload admission stages and validates a bundle before returning a run id.
- Idempotent upload retries compare normalized source manifest identity, not archive bytes. - Idempotent upload retries compare normalized source manifest identity, not archive bytes.
- Runtime backend registration remains app-owned. - 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.

View File

@@ -18,7 +18,7 @@ The canonical user-facing config reference is `docs/config.md`.
## Config Fields Used ## Config Fields Used
The package defines all user-visible config fields: `server.http`, `secrets`, `pipelines`, source and destination backend fields, validation policy, publish policy, transform policy, path mapping, links, and transfer policy. 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.
## Adapters Used ## Adapters Used
@@ -26,11 +26,11 @@ No external storage adapters are used directly. The package exposes normalized c
## State And Manifest Behavior ## State And Manifest Behavior
The package does not parse source manifests or destination state. It validates config values that later affect manifest validation and destination state, such as publish/transform combinations, links, transfer policy, backend roots, S3 prefix shape, and HTTP upload source settings. 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.
## Skip And Resume Behavior ## Skip And Resume Behavior
The package has no runtime skip or resume behavior. It provides transfer policy values that publish planning later applies to destination comparison outcomes. The package has no runtime skip or resume behavior. Publish planning later applies workflow values and per-run force options to destination catalog outcomes.
## Failure Behavior ## Failure Behavior

View File

@@ -4,23 +4,23 @@ Audience: developers and LLM coding agents changing `internal/publish`.
## Purpose ## Purpose
`internal/publish` plans and executes publication for one validated source bundle and one destination bundle path. It owns destination comparison mapping, output selection, URL planning, managed cleanup selection, replacement safety, and destination state projection. `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, destination bundle path, path mapping mode, publish policy, transform policy, optional link policy, transformer resolver, transfer policy, distributor version, and force flag. 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 from planning is a `Plan` with action, reason, destination identity, selected outputs, optional existing state, optional primary URL, and force metadata. Execution writes selected source outputs, generated outputs, and `.distributor.json` for executable publish or replacement actions. 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 ## Boundaries
The package does not parse CLI flags, load config files, open concrete adapters, discover source bundles, select fixed-path bundle candidates, register transforms, or render command output. The app layer supplies validated request data and concrete dependencies. The package 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.
External destination state semantics are documented in `docs/integrations/destination-state.md`. External destination state semantics are documented in `docs/integrations/destination-state.md`.
## Config Fields Used ## Config Fields Used
The package consumes already-defaulted config values for destination `publish`, `transform`, `links`, `transfer`, and path mapping mode. It uses `config.ValidatePublishTransformPolicy` for publish/transform consistency. 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.
## Adapters Used ## Adapters Used
@@ -28,21 +28,37 @@ The package depends on `internal/storage.Backend` for source and destination IO,
## State And Manifest Behavior ## State And Manifest Behavior
Planning inspects destination state through `internal/state`, compares it with the source manifest, and maps comparison outcomes plus transfer policy into actions: `publish_new`, `replace_older`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, or `fail_unmanaged`. Planning inspects destination state through `internal/state` and maps catalog conditions into actions:
Execution writes destination state after selected outputs are written. Destination state includes copied source output metadata, generated output metadata, embedded source manifest, link metadata when configured, pipeline id, destination id, and publication timestamp. - `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.
## Skip And Resume Behavior Superseded legacy state is identified by schema number and planned through catalog output projection. A successful publish writes schema version `4` catalog state.
`skip_same` and `skip_destination_newer` execute as no-ops. Normal replacement removes only managed output paths from existing state plus `.distributor.json`; this allows retries without broad deletion. Failed writes trigger cleanup of outputs written during that failed attempt where practical. 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.
Forced replacement is explicit per request and deletes the bounded destination bundle path before writing new outputs and state. ## 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 ## Failure Behavior
Planning fails for incomplete requests, invalid publish/transform policy, output path collisions, invalid destination state, unmanaged destination content without force, conflict outcomes not allowed by transfer policy, unresolved transforms, invalid Markdown output selection, and invalid link URL planning. 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 publish or replacement actions. 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 ## Tests To Inspect
@@ -56,8 +72,9 @@ Execution fails on delete, read, transform output, write, state validation, stat
- Planning is deterministic for the same request and destination state. - Planning is deterministic for the same request and destination state.
- Destination bundle paths are caller-supplied and backend-root-relative. - Destination bundle paths are caller-supplied and backend-root-relative.
- URL generation uses URL path semantics and never infers public URLs from backend config. - URL generation uses URL path semantics and never infers public URLs from backend config.
- Normal replacement deletes only managed paths recorded in existing state plus `.distributor.json`. - Additive workflow never adopts unmanaged storage content.
- Forced replacement deletes only within the supplied destination bundle path. - 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. - Destination state is written after selected outputs are written.
- Transform resolution stays behind a caller-supplied interface. - Transform resolution stays behind a caller-supplied interface.
- Unmanaged content is claimed only by explicit force. - Unmanaged content is claimed only by explicit force.

View File

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

View File

@@ -26,13 +26,15 @@ Local, SSH/SFTP, and S3-compatible adapters implement `storage.Backend`. `intern
## State And Manifest Behavior ## State And Manifest Behavior
Storage owns `.distributor.json` path helpers through `StateFileName`, `StatePath`, and `ManagedBundleTargets`. It does not parse source manifests or destination state. Storage owns `.distributor.json` path helpers through `StateFileName`, `StatePath`, `ManagedOutputTargets`, and `ManagedBundleTargets`. It does not parse source manifests or destination state.
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. 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.
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.
## Skip And Resume Behavior ## Skip And Resume Behavior
Storage has no publication skip policy. It supplies `HasAny` for unmanaged-content checks, `DeleteManagedBundle` target construction for normal replacement cleanup, and `DeletePrefix` semantics for explicit forced replacement. 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.
## Failure Behavior ## Failure Behavior
@@ -42,6 +44,7 @@ Storage errors use typed categories: not found, already exists, not empty, inval
- `internal/storage/*_test.go` - `internal/storage/*_test.go`
- `internal/storage/fake/*_test.go` - `internal/storage/fake/*_test.go`
- `internal/app/reconcile_state_test.go`
- `internal/adapters/local/*_test.go` - `internal/adapters/local/*_test.go`
- `internal/adapters/ssh/*_test.go` - `internal/adapters/ssh/*_test.go`
- `internal/adapters/s3/*_test.go` - `internal/adapters/s3/*_test.go`
@@ -51,6 +54,6 @@ Storage errors use typed categories: not found, already exists, not empty, inval
- Logical paths are clean relative slash-separated paths confined to the backend root. - Logical paths are clean relative slash-separated paths confined to the backend root.
- Core packages never import concrete adapters. - Core packages never import concrete adapters.
- `storage.List` returns deterministic sorted entries. - `storage.List` returns deterministic sorted entries.
- Managed deletion targets are recorded outputs plus `.distributor.json`. - 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. - Prefix deletion is bounded to the requested logical prefix.
- Runtime registration remains app-owned. - Runtime registration remains app-owned.

View File

@@ -56,43 +56,105 @@ Destination path mapping controls where each source bundle is published beneath
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 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 state record. It records the pipeline id, destination id, publication time, source manifest, copied outputs, generated outputs, and optional public URL metadata. 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. `manifest.json` from the source bundle is not copied as destination state.
## Destination State And Retry Behavior ## Catalog Publish Behavior
`distributor` compares the source manifest to destination `.distributor.json` before writing: `distributor` plans from the current source bundle, destination workflow, destination storage content, and `.distributor.json`.
- No destination state and no destination content: publish new outputs. - No valid state and no destination content: publish new outputs.
- Matching destination state: skip as already published. - No valid state and existing destination content: fail as unmanaged unless `--force` is used.
- Older destination state for the same source id: replace if transfer policy allows it. - Valid catalog state with `workflow: additive`: write planned outputs and retain unrelated catalog outputs.
- Newer destination state: skip by default. - Valid catalog state with `workflow: replacement`: write planned outputs and remove omitted outputs owned by the selected pipeline and destination.
- Invalid destination state, identity mismatch, different source id, or same-created digest mismatch: fail by default. - Planned output path exists in storage but is not recorded in valid catalog state - fail as unmanaged unless `--force` is used.
- Content without `.distributor.json`: fail as unmanaged content by default. - 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.
Normal replacement deletes only managed output paths recorded in `.distributor.json` plus the state file, then verifies the destination bundle path is empty before writing new outputs and state. `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.
If a write fails after some outputs were written, `distributor` attempts to delete outputs from that failed attempt so a retry does not treat partial outputs as unmanaged content. Operators should still inspect the destination after a failed write before retrying. `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. 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
```
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 ## 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`, or SSH `known_hosts` entries. `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: Review these action labels before publishing:
- `publish_new`: destination is empty and unmanaged. - `publish_new`: destination state is absent and the destination bundle path is empty.
- `replace_older`: destination state is older than the source. - `upsert_additive`: additive workflow will write planned outputs into valid catalog state.
- `skip_same`: destination state already matches the source. - `replace_catalog`: replacement workflow will write planned outputs and remove omitted outputs for the current owner.
- `skip_destination_newer`: destination state is newer than the source and is skipped. - `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 replacement selected because `--force` is present and policy permits it. - `force_replace`: destructive catalog replacement selected because `--force` is present for unmanaged content, a planned unmanaged path collision, invalid state, or unsupported future state.
- `error`: planning or execution failed for that destination. - `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.
Fixed destinations add fixed-path warnings during dry runs, including the selected source bundle and replacement warnings when the destination root would be replaced. 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.
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 ## Forced Replacement Workflow
@@ -103,18 +165,15 @@ go run ./cmd/distributor run --config <config-path> --dry-run --force
go run ./cmd/distributor run --config <config-path> --force go run ./cmd/distributor run --config <config-path> --force
``` ```
Forced replacement can claim unmanaged non-empty destination paths. State conflicts require both `--force` and transfer policy that permits replacement: 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`.
- newer destination state requires `transfer.on_destination_newer: replace`; 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: "."`.
- conflict outcomes require `transfer.on_conflict: replace`.
Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete parent paths, sibling paths, or storage outside the destination bundle path. For fixed destinations, the destination bundle path is the backend root, so a forced replacement can clear that configured root.
`--force` applies only to the current invocation. There is no config field that enables forced replacement by default. `--force` applies only to the current invocation. There is no config field that enables forced replacement by default.
## HTTP Upload Operation ## 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`. Each bearer token maps to exactly one configured upload pipeline. Token values come from the process environment or `secrets.directory`, not from YAML literal values. 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: Start the maintained local example:
@@ -132,36 +191,25 @@ curl http://127.0.0.1:8080/healthz
Upload one tar or tar.gz source bundle archive: Upload one tar or tar.gz source bundle archive:
```sh ```sh
curl -X POST http://127.0.0.1:8080/upload \ curl -X POST http://127.0.0.1:8080/v1/pipelines/example-http-upload/upload \
-H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \ -H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
-H "Content-Type: application/gzip" \ -H "Content-Type: application/gzip" \
--data-binary @bundle.tar.gz --data-binary @bundle.tar.gz
``` ```
For safe producer retries, include an idempotency key that is stable for the producer operation: For safe producer retries, include an idempotency key that is stable for the same producer run and different for each distinct run:
```sh ```sh
curl -X POST http://127.0.0.1:8080/upload \ curl -X POST http://127.0.0.1:8080/v1/pipelines/example-http-upload/upload \
-H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \ -H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
-H "Content-Type: application/gzip" \ -H "Content-Type: application/gzip" \
-H "Idempotency-Key: producer.run.20260604T120000Z" \ -H "Idempotency-Key: producer.run.20260604T120000Z" \
--data-binary @bundle.tar.gz --data-binary @bundle.tar.gz
``` ```
Go producer applications can use `pkg/upload` instead of constructing archives and HTTP requests directly. The package sends `Idempotency-Key` on every upload, derives `/upload` from the configured endpoint, and reuses the same key and replayable request body for safe retries: Go producer applications can use `pkg/upload` instead of constructing archives and HTTP requests directly. See [Upstream Producer Integration](consumers/api.md).
```go 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`.
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: "http://127.0.0.1:8080",
Token: token,
})
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
Root: "examples/source-bundle",
IdempotencyKey: "producer.run.20260604T120000Z",
})
```
The maintained example client uses the local upload server and reads the token from `DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN`. It generates an idempotency key by default; set `DISTRIBUTOR_EXAMPLE_UPLOAD_IDEMPOTENCY_KEY` when a retry must be stable across separate process runs.
```sh ```sh
go run ./examples/upload-client go run ./examples/upload-client
@@ -183,9 +231,9 @@ Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Co
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. Upload admission is bounded by `server.http.queue_size`. Publication concurrency is bounded by `server.http.max_concurrency`, and the coordinator does not run two uploads for the same pipeline at the same time.
`Idempotency-Key` is optional for raw HTTP clients. When present, it is scoped to the authenticated pipeline. Reusing the same key with the same normalized source manifest returns the original accepted run response and does not enqueue another run. Reusing the key with a different source manifest returns `409 Conflict`. If another request with the same key is still being staged before its manifest is known, the server returns a retryable `409 Conflict`. Idempotency records are memory-only and expire with completed upload status records. `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`.
The upload server accepts `application/x-tar`, `application/gzip`, and `application/x-gzip`. Archives are extracted into a temporary staging directory, must contain exactly one root-level `manifest.json`, and must validate as one complete source bundle before a run id is issued. Per-source `max_upload_size` bounds both uploaded archive size and extracted bundle size. The implementation also caps extracted file count. The upload server accepts `application/x-tar`, `application/gzip`, and `application/x-gzip`. Archives are extracted into a temporary staging directory, must contain exactly one root-level `manifest.json`, and must validate as one complete source bundle before a run id is issued. Per-source `max_upload_size` bounds both uploaded archive size and extracted bundle size.
The default bind address is private loopback. Put TLS, public routing, rate limiting, and external access policy in a reverse proxy or deployment layer. The default bind address is private loopback. Put TLS, public routing, rate limiting, and external access policy in a reverse proxy or deployment layer.
@@ -205,7 +253,7 @@ S3 execution uses the AWS SDK for Go v2. See [S3-Compatible Storage Integration]
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 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.
Normal replacement and failed-write cleanup delete only managed output objects recorded in `.distributor.json` plus the state object. Forced replacement deletes objects under the bounded destination bundle prefix. Distributor does not manage bucket versioning or delete markers. Normal 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.
## Secrets Operation ## Secrets Operation
@@ -220,8 +268,10 @@ Use these recovery boundaries:
- For source validation failures, regenerate the source bundle and manifest together. - 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 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 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 failed writes, inspect the destination bundle path, remove only confirmed partial outputs if needed, then rerun `--dry-run`.
- For state conflicts, verify the source, pipeline, destination, and existing `.distributor.json` before considering `--force`.
- For HTTP upload failures, inspect `/runs/<run-id>` while retained; after expiry or restart, rely on destination state and logs/output from the publishing run. - 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.
Do not edit `.distributor.json` during normal recovery. Treat it as the managed state record used for comparison and safe cleanup. Do not edit `.distributor.json` during normal recovery. Treat it as the managed state record used for comparison and safe cleanup.

View File

@@ -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. `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; - `distributor` state schema version;
- pipeline id; - state creation and update timestamps;
- destination id; - catalog state mode;
- publication timestamp; - owner identity for each managed output;
- the normalized source manifest used for publication; - compact source identity for each managed output;
- metadata for copied source outputs; - metadata for copied source outputs;
- metadata for generated outputs, such as HTML files; - metadata for generated outputs, such as HTML files;
- optional URL metadata for published outputs; - optional URL metadata for published outputs;
@@ -110,38 +114,31 @@ A representative destination state file is:
```json ```json
{ {
"schema_version": 1, "schema_version": 4,
"distributor_version": "0.1.0", "distributor_version": "0.1.0",
"pipeline_id": "weather-daily", "created_at": "2026-05-30T11:12:00Z",
"destination_id": "static-html", "updated_at": "2026-05-30T11:12:00Z",
"published_at": "2026-05-30T11:12:00Z", "state": {
"source": { "mode": "catalog"
"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/"
}, },
"outputs": [ "outputs": [
{ {
"path": "index.html", "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", "kind": "generated",
"source_path": "report.md", "source_path": "report.md",
"transform": "markdown_to_html", "transform": "markdown_to_html",
"sha256": "sha256:...", "sha256": "sha256:...",
"size": 23456, "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`: Destination comparison rules are based on `.distributor.json`:
- No `.distributor.json`: publish normally only if the destination bundle path is empty. - 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 catalog state with additive workflow: write planned outputs and retain unrelated managed outputs.
- Existing state has the same source id and an older source `created`: replace, subject to destructive-operation safety rules. - Existing catalog state with replacement workflow: write planned outputs and remove omitted outputs for the current pipeline and destination owner.
- Existing state has the same source id and a newer source `created`: skip because the destination is newer than the source. - Planned paths that collide with unmanaged storage content fail by default.
- Existing state has the same source id and same `created` but different digest: fail as a conflict. - Invalid or unsupported destination state fails by default.
- Existing state has a different source id: fail as a conflict. - Explicit forced replacement may clear the bounded destination bundle path after dry-run review.
## Publication and Transform Policy ## 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. 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 ## Dependency Policy
@@ -197,11 +194,12 @@ Use this current layout unless the project has a documented reason to differ:
- `cmd/distributor`: application entrypoint only. - `cmd/distributor`: application entrypoint only.
- `pkg/bundle`: public producer-facing source manifest model, digest logic, parsing, manifest building, complete local bundle writing, and local validation helpers. - `pkg/bundle`: public producer-facing source manifest model, digest logic, parsing, manifest building, complete local bundle writing, and local validation helpers.
- `pkg/upload`: public producer-facing HTTP upload client built on `pkg/bundle`.
- `internal/app`: application orchestration and top-level use cases. - `internal/app`: application orchestration and top-level use cases.
- `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring. - `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring.
- `internal/config`: configuration structs, defaults, loading, precedence, and validation. - `internal/config`: configuration structs, defaults, loading, precedence, and validation.
- `internal/bundle`: storage-backed source bundle discovery and validation over the public manifest contract. - `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/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/storage`: backend interfaces, shared path/resource types, backend registry, and storage errors.
- `internal/adapters/local`: local filesystem backend. - `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/adapters/s3`: S3-compatible object storage backend.
- `internal/transform`: transform interfaces, registry, planning, and shared transform models. - `internal/transform`: transform interfaces, registry, planning, and shared transform models.
- `internal/transform/markdown`: Markdown-to-HTML implementation. - `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/notify`: notification interface and MVP no-op notifier.
- `internal/logging`: logging setup and shared logging helpers. - `internal/logging`: logging setup and shared logging helpers.
@@ -244,7 +242,7 @@ Pipeline configuration should express:
- per-destination transform policy; - per-destination transform policy;
- per-destination public link policy; - per-destination public link policy;
- validation behavior; - validation behavior;
- destination conflict/replacement behavior. - per-destination workflow and retention behavior.
## Modules and Registries ## 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. 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. 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/`. Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
When changing architecture, config, CLI behavior, adapters, manifest/state contracts, transform behavior, publish behavior, or component contracts, update the relevant docs and examples in the same change. When changing architecture, config, CLI behavior, adapters, manifest/state contracts, transform behavior, publish behavior, public package/API behavior, or component contracts, update the relevant docs and examples in the same change.
The source manifest and destination `.distributor.json` schemas should have canonical documentation once implemented. Example configs should be valid and load-tested where practical. The source manifest and destination `.distributor.json` schemas should have canonical documentation once implemented. Producer-facing package and API workflows belong under `docs/consumers/`. Example configs should be valid and load-tested where practical.
## Non-Goals ## Non-Goals

View File

@@ -12,18 +12,18 @@ Use it with `docs/policy/architecture.md` and `docs/policy/documentation.md`.
- `internal/cli`: standard-library command parsing, flags, help text, and command wiring. - `internal/cli`: standard-library command parsing, flags, help text, and command wiring.
- `internal/config`: YAML configuration structs, loading, defaults, and validation. - `internal/config`: YAML configuration structs, loading, defaults, and validation.
- `internal/bundle`: storage-backed source bundle discovery and validation using the public manifest contract. - `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/storage`: backend interface, registry, logical path rules, typed errors, and shared storage helpers.
- `internal/adapters/local`: local filesystem backend. - `internal/adapters/local`: local filesystem backend.
- `internal/adapters/ssh`: SSH/SFTP backend. - `internal/adapters/ssh`: SSH/SFTP backend.
- `internal/adapters/s3`: S3-compatible object storage backend. - `internal/adapters/s3`: S3-compatible object storage backend.
- `internal/storage/fake`: in-memory backend for tests. - `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`: transform interface and registry.
- `internal/transform/markdown`: Markdown-to-HTML transform. - `internal/transform/markdown`: Markdown-to-HTML transform.
- `internal/notify`: notification interface and current no-op notifier. - `internal/notify`: notification interface and current no-op notifier.
- `internal/testutil`: shared test fixtures. Production code must not import this package. - `internal/testutil`: shared test fixtures. Production code must not import this package.
- `docs`: current user, operator, policy, internal, and roadmap documentation. - `docs`: current user, operator, consumer, integration, policy, internal, and roadmap documentation.
- `examples`: copyable example configs and source bundles. - `examples`: copyable example configs and source bundles.
Do not create new top-level package families such as public `pkg/...` packages Do not create new top-level package families such as public `pkg/...` packages
@@ -211,5 +211,7 @@ Follow `docs/policy/documentation.md`.
- Keep `docs/config.md` canonical for user-facing config reference. - Keep `docs/config.md` canonical for user-facing config reference.
- Keep `docs/cli.md` canonical for command syntax and workflows. - Keep `docs/cli.md` canonical for command syntax and workflows.
- Keep `docs/operations.md` canonical for operational and recovery behavior. - Keep `docs/operations.md` canonical for operational and recovery behavior.
- Keep `docs/consumers/` canonical for public package and consumer API workflows.
- Keep `docs/integrations/` canonical for external file-format and wire-protocol contracts.
- Keep `docs/internal/` focused on implemented package contracts. - Keep `docs/internal/` focused on implemented package contracts.
- Update docs in the same change as behavior when public behavior, config, CLI, examples, or internal contracts change. - Update docs in the same change as behavior when public behavior, public packages/APIs, config, CLI, examples, or internal contracts change.

View File

@@ -2,12 +2,13 @@
## Purpose ## Purpose
Project documentation must help four audiences: Project documentation must help five audiences:
1. users who need to run the application; 1. users who need to run the application;
2. administrators/operators who need to configure and operate it; 2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely; 3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants. 4. LLM coding agents that need clear scope, boundaries, and invariants;
5. developers and LLM coding agents integrating this project from another codebase.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition. Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
@@ -42,11 +43,14 @@ Canonical homes:
- project purpose and quickstart: `README.md` - project purpose and quickstart: `README.md`
- development principles: `docs/policy/architecture.md` - development principles: `docs/policy/architecture.md`
- public HTTP API reference: `docs/api.md`
- configuration reference: `docs/config.md` - configuration reference: `docs/config.md`
- CLI reference: `docs/cli.md` - CLI reference: `docs/cli.md`
- operations and recovery: `docs/operations.md` - operations and recovery: `docs/operations.md`
- troubleshooting: `docs/troubleshooting.md` - troubleshooting: `docs/troubleshooting.md`
- public API/package consumer guidance: `docs/consumers/`
- implemented internals: `docs/internal/` - implemented internals: `docs/internal/`
- external protocol, service, and file-format contracts: `docs/integrations/`
- future work: `docs/roadmap/` - future work: `docs/roadmap/`
- contributor workflow: `docs/policy/development.md` - contributor workflow: `docs/policy/development.md`
- copyable examples: `examples/` - copyable examples: `examples/`
@@ -119,6 +123,31 @@ Recommended:
- `docs/troubleshooting.md` - `docs/troubleshooting.md`
- validated examples under `examples/` - 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 ## Required Documents
### README.md ### 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. 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 ### docs/policy/development.md
**Audience:** developers, LLM coding agents **Audience:** developers, LLM coding agents
@@ -244,6 +299,35 @@ Each entry should include:
- safe fix; - safe fix;
- relevant links. - relevant links.
### docs/consumers/
**Audience:** developers and LLM coding agents integrating this project from another codebase
Required for projects with public packages, SDKs, client APIs, plugin APIs, or other application-facing integration surfaces.
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
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/ ### docs/internal/
**Audience:** developers, LLM coding agents **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. 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. Use one file per integration where useful.
@@ -346,8 +432,10 @@ Before merging documentation changes, verify:
- README is concise and orientation-focused. - README is concise and orientation-focused.
- `docs/policy/architecture.md` describes development principles. - `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/`. - Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals. - User-facing docs avoid unnecessary internals.
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
- Developer-facing docs preserve boundaries and invariants. - Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema. - Config examples match the schema.
- CLI examples match real commands and flags. - CLI examples match real commands and flags.

View File

@@ -33,6 +33,21 @@ internal docs.
- Resumable upload support. - Resumable upload support.
- Streaming upload protocols. - 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 ## Destination Backends
- GitHub Gist destination backend support. - GitHub Gist destination backend support.
@@ -68,6 +83,7 @@ internal docs.
- Mutual TLS or other in-app identity mechanisms. - Mutual TLS or other in-app identity mechanisms.
- In-app TLS. - In-app TLS.
- Public exposure defaults. - Public exposure defaults.
- In-app upload rate limiting.
- Browser UI. - Browser UI.
## Boundaries ## Boundaries
@@ -76,5 +92,12 @@ internal docs.
contract. contract.
- Current upload status, queue, and idempotency state are memory-only. - Current upload status, queue, and idempotency state are memory-only.
- Producers submit complete tar or gzip-compressed tar source bundles today. - 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 - Public access policy, TLS termination, and rate limiting belong outside
`distributor` unless a future implementation changes that boundary. `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.

View File

@@ -24,7 +24,9 @@ Reference: [Configuration](config.md#config-file-loading).
Symptom: `parse config ... field not found`. Symptom: `parse config ... field not found`.
Likely cause: the YAML contains a key that is not part of the implemented config schema. 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: Diagnostic:
@@ -32,9 +34,9 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --dry-run go run ./cmd/distributor run --config <config-path> --dry-run
``` ```
Safe fix: remove or rename unsupported fields using the canonical config reference. Safe fix: remove unsupported fields using the canonical config reference. Destination behavior is configured with `workflow`, `publish`, `transform`, `path_mapping`, `links`, and `retention`.
Reference: [Configuration](config.md). Reference: [Configuration](config.md#destination-fields).
## Backend Name Or Placement Is Invalid ## Backend Name Or Placement Is Invalid
@@ -69,6 +71,42 @@ Safe fix: use either `distributor validate <path>` / `distributor inspect <path>
Reference: [CLI](cli.md#validate). Reference: [CLI](cli.md#validate).
## 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
go run ./cmd/distributor reconcile-state --help
rg -n 'pipelines:|destinations:|id:' <config-path>
cat <destination-path>/.distributor.json
```
Safe fix: pass the configured `--config`, `--pipeline`, and `--destination` values that identify the destination root containing the state file.
Reference: [CLI](cli.md#reconcile-state).
## 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
go run ./cmd/distributor prune --help
rg -n 'retention:|prune:|pipelines:|destinations:|id:' <config-path>
cat <destination-path>/.distributor.json
```
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.
Reference: [CLI](cli.md#prune).
## Output Format Is Invalid ## Output Format Is Invalid
Symptom: `format must be text or json`. Symptom: `format must be text or json`.
@@ -151,9 +189,9 @@ Reference: [Operations](operations.md#cleanup-and-recovery).
## Destination Has Unmanaged Content ## Destination Has Unmanaged Content
Symptom: `destination has content but no distributor state` or a plan reason containing `fail_unmanaged`. 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`, so `distributor` will not claim it by default. 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: Diagnostic:
@@ -161,48 +199,91 @@ Diagnostic:
find <destination-path> -maxdepth 2 -print find <destination-path> -maxdepth 2 -print
``` ```
Safe fix: choose an empty destination path, move unrelated files aside, or preview `run --dry-run --force` only after confirming the reported bundle path is safe to replace. 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.
Reference: [Operations](operations.md#forced-replacement-workflow). Reference: [Operations](operations.md#forced-replacement-workflow).
## Destination State Conflicts With Source ## Destination State Is Invalid Or Unsupported
Symptom: `fail_conflict`, `destination source id differs`, `same id and created time but different digest`, `pipeline id ... does not match`, or `destination id ... does not match`. Symptom: `fail_conflict`, `parse distributor state`, `state schema_version must be 4`, or `unsupported future destination state`.
Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, or same-created source with different content. Likely cause: `.distributor.json` is invalid JSON, has invalid catalog fields, or uses an unsupported future schema.
Diagnostic: Diagnostic:
```sh ```sh
cat <destination-path>/.distributor.json 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 the source and destination are intended to match. Use a separate destination path for unrelated content. To replace the existing state, configure `transfer.on_conflict: replace`, preview with `--dry-run --force`, then publish with `--force`. 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`.
Reference: [Operations](operations.md#destination-state-and-retry-behavior). Reference: [Operations](operations.md#forced-replacement-workflow).
## Destination Is Newer Than Source ## Destination Uses Superseded Legacy State
Symptom: `skip_destination_newer` or `destination is newer and replacement requires --force`. 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 state records a source manifest with a later `created` timestamp than the current source. 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: Diagnostic:
```sh ```sh
go run ./cmd/distributor run --config <config-path> --dry-run --format json cat <destination-path>/.distributor.json
go run ./cmd/distributor run --config <config-path> --dry-run
``` ```
Safe fix: keep the default skip behavior unless replacement is intentional. To replace newer state, configure `transfer.on_destination_newer: replace`, preview with `--dry-run --force`, then publish with `--force`. 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: [Operations](operations.md#forced-replacement-workflow). 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 ## Forced Replacement Appears In A Plan
Symptom: dry-run output includes `force_replace`. Symptom: dry-run output includes `force_replace`.
Likely cause: the run used `--force`, and planning selected a supported destructive replacement. 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: Diagnostic:
@@ -210,15 +291,15 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --dry-run --force go run ./cmd/distributor run --config <config-path> --dry-run --force
``` ```
Safe fix: inspect the pipeline id, destination id, backend, and bundle path. Proceed only if deleting everything inside that destination bundle path is intended. 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.
Reference: [Operations](operations.md#forced-replacement-workflow). Reference: [Operations](operations.md#forced-replacement-workflow).
## Output Path Collision ## Output Path Collision
Symptom: `destination output path collision`. Symptom: `destination output path collision` or `destination output path ... exists but is not managed by catalog state`.
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`. 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: Diagnostic:
@@ -226,7 +307,7 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --dry-run go run ./cmd/distributor run --config <config-path> --dry-run
``` ```
Safe fix: adjust source files or publish/transform policy so copied and generated outputs do not collide. 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.
Reference: [Configuration](config.md#publish-and-transform-policy). Reference: [Configuration](config.md#publish-and-transform-policy).
@@ -244,7 +325,7 @@ find <destination-path> -maxdepth 2 -print
Safe fix: inspect the destination bundle path printed in the error. `distributor` attempts to remove outputs from the failed attempt, but operators should verify the destination before retrying. Rerun `--dry-run` before publishing again. Safe fix: inspect the destination bundle path printed in the error. `distributor` attempts to remove outputs from the failed attempt, but operators should verify the destination before retrying. Rerun `--dry-run` before publishing again.
Reference: [Operations](operations.md#destination-state-and-retry-behavior). Reference: [Operations](operations.md#catalog-publish-behavior).
## JSON Run Result Has `ok: false` ## JSON Run Result Has `ok: false`
@@ -266,224 +347,14 @@ Reference: [CLI](cli.md#output-and-exit-behavior).
Symptom: `load secrets directory ... no such file or directory`, `permission denied`, or `secret filename ... is invalid`. Symptom: `load secrets directory ... no such file or directory`, `permission denied`, or `secret filename ... is invalid`.
Likely cause: `secrets.directory` points to a missing or unreadable directory, or it contains a regular file whose name is not a valid credential variable name. 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: Diagnostic:
```sh ```sh
ls -ld <secrets-directory> ls -la <secrets-directory>
find <secrets-directory> -maxdepth 1 -type f -printf '%f\n'
``` ```
Safe fix: mount or create the directory, adjust permissions for the service user, or rename/remove invalid secret files. Secret filenames must match `[A-Za-z_][A-Za-z0-9_]*`. Safe fix: create the directory, fix permissions, or rename secret files to valid environment-variable-style names.
Reference: [Configuration](config.md#secrets). Reference: [Configuration](config.md#secrets).
## Credential Variable Is Missing Or Empty
Symptom: `credential environment variable ... is not set`, `credential environment variable ... is empty`, or S3 authentication errors such as `AccessDenied`, `InvalidAccessKeyId`, or `SignatureDoesNotMatch`.
Likely cause: configured S3 credential variable names are not available through the process environment or `secrets.directory`, are empty, or do not authorize the requested bucket/prefix.
Diagnostic:
```sh
env | cut -d= -f1 | rg '^(<access-key-variable>|<secret-key-variable>)$'
ls -l <secrets-directory>
```
Safe fix: provide both configured S3 credential values, correct IAM/service permissions, or omit explicit credential fields to use the AWS SDK default credential chain.
Reference: [Configuration](config.md#s3-compatible-backend).
## Secret File Is Ignored In Favor Of Environment
Symptom: `secret ... ignored because the real environment already has that variable`.
Likely cause: the same credential name exists in the process environment and `secrets.directory` with different values.
Diagnostic:
```sh
env | cut -d= -f1 | rg '^<variable-name>$'
ls -l <secrets-directory>/<variable-name>
```
Safe fix: remove one source of the credential or intentionally keep the process environment value. `distributor` does not print either value.
Reference: [Operations](operations.md#secrets-operation).
## SSH Auth Is Not Configured
Symptom: `no SSH auth methods configured`.
Likely cause: no SSH agent is available and `ssh_key_file` is missing or unreadable.
Diagnostic:
```sh
test -n "$SSH_AUTH_SOCK" && ssh-add -l
ls -l <ssh-key-file>
```
Safe fix: start an SSH agent with an appropriate key loaded, or configure a readable private key with `ssh_key_file`.
Reference: [Configuration](config.md#sshsftp-backend).
## SSH Host Key Fails
Symptom: `host key ... is unknown`, `known_hosts is required`, or `host key ... has changed`.
Likely cause: strict host key checking has no trusted key, `accept-new` cannot persist a new key, or the remote host key differs from the stored key.
Diagnostic:
```sh
ls -l <known-hosts-path>
ssh-keygen -F <host> -f <known-hosts-path>
```
Safe fix: pre-populate `known_hosts` for `strict`, configure a writable `known_hosts` path for `accept-new`, or verify the server identity before updating a changed key. Do not disable host key checking to bypass an unexpected changed key.
Reference: [Operations](operations.md#sshsftp).
## S3 Prefix Is Invalid
Symptom: `prefix must be a clean relative slash-separated path`.
Likely cause: the S3 prefix contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed.
Diagnostic:
```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`.
Reference: [Configuration](config.md#s3-compatible-backend).
## S3 Location Or Connectivity Fails
Symptom: `NoSuchBucket`, `InvalidBucketName`, `not_found`, endpoint connection failures, or TLS/network errors.
Likely cause: endpoint, bucket, prefix, region, path-style mode, network routing, or credentials are wrong for the service.
Diagnostic:
```sh
go run ./cmd/distributor run --config <config-path> --dry-run
curl -I <endpoint>
```
Safe fix: verify `endpoint`, `bucket`, `region`, `prefix`, and `force_path_style`. For S3-compatible services, keep `force_path_style: true` unless the service requires virtual-host addressing. Distributor does not provide an insecure TLS bypass setting.
Reference: [Operations](operations.md#s3-compatible-storage).
## HTTP Server Cannot Bind
Symptom: `bind HTTP server ... address already in use`.
Likely cause: another process is listening on `server.http.bind`.
Diagnostic:
```sh
ss -ltnp | rg '<port>'
```
Safe fix: stop the conflicting process or configure a different bind address.
Reference: [Configuration](config.md#serverhttp).
## HTTP Upload Token Is Missing Or Duplicated
Symptom: `upload token environment variable ... is not set`, `... is empty`, or `upload token environment variables ... resolve to the same value`.
Likely cause: an `http_upload` source references a missing/empty `token_env`, or two upload pipelines resolve to the same bearer token.
Diagnostic:
```sh
rg -n 'token_env:' <config-path>
env | cut -d= -f1 | rg '^<token-variable>$'
ls -l <secrets-directory>/<token-variable>
```
Safe fix: provide one distinct non-empty token value per upload pipeline through the process environment or `secrets.directory`. Do not put literal tokens in YAML.
Reference: [Configuration](config.md#http-upload-source-backend).
## Upload Request Is Unauthorized
Symptom: `POST /upload` returns `401`.
Likely cause: the request lacks `Authorization: Bearer <token>`, has an empty token, or uses a token that does not match any configured upload pipeline.
Diagnostic:
```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.
Reference: [Operations](operations.md#http-upload-operation).
## Upload Request Is Rejected Before A Run ID
Symptom: `POST /upload` returns `400`, `413`, `415`, or `503`.
Likely cause: the request included a `pipeline` or `pipeline_id` query, archive content is malformed, the body exceeds size limits, content type is unsupported, or the in-memory upload queue is full.
Diagnostic:
```sh
tar -tf bundle.tar
tar -tzf bundle.tar.gz
rg -n 'max_upload_size|queue_size|max_concurrency' <config-path>
```
Safe fix: send one valid tar or tar.gz source bundle archive with `Content-Type: application/x-tar`, `application/gzip`, or `application/x-gzip`; remove pipeline query parameters; reduce archive size or raise the configured limit; retry after queue pressure drops.
Reference: [Operations](operations.md#http-upload-operation).
## Upload Idempotency Conflict
Symptom: `POST /upload` returns `409`.
Likely cause: the request reused an `Idempotency-Key` for the same authenticated pipeline with a different source manifest, or another request with the same key is still being staged before its manifest is known.
Diagnostic:
```sh
curl -i -X POST http://127.0.0.1:8080/upload \
-H "Authorization: Bearer $DISTRIBUTOR_UPLOAD_TOKEN" \
-H "Content-Type: application/gzip" \
-H "Idempotency-Key: <key>" \
--data-binary @bundle.tar.gz
```
Safe fix: if the response includes `"retryable":true`, retry the same upload later with the same key. Otherwise, inspect the producer operation and use the same key only for the same source bundle.
Reference: [HTTP Upload API Contract](integrations/http-upload.md#post-upload).
## Upload Status Is Missing
Symptom: `GET /runs/<run_id>` returns `404`.
Likely cause: the run id is wrong, the process restarted, or the retained status record expired after `server.http.retention`.
Diagnostic:
```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 upload admission. Increase retention if operators need a longer status window.
Reference: [Operations](operations.md#http-upload-operation).

View 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

View File

@@ -7,6 +7,7 @@ pipelines:
- id: local-source-archive - id: local-source-archive
backend: local backend: local
path: workspace/published/archive-and-latest/archive path: workspace/published/archive-and-latest/archive
workflow: additive
path_mapping: path_mapping:
mode: preserve_relative mode: preserve_relative
publish: publish:
@@ -15,6 +16,7 @@ pipelines:
- id: local-html-latest - id: local-html-latest
backend: local backend: local
path: workspace/published/archive-and-latest/latest path: workspace/published/archive-and-latest/latest
workflow: replacement
path_mapping: path_mapping:
mode: fixed mode: fixed
links: links:

View File

@@ -9,11 +9,15 @@ server:
queue_size: 16 queue_size: 16
max_concurrency: 1 max_concurrency: 1
retention: 24h retention: 24h
upload_tokens:
- id: example-uploader
token_env: DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN
allow_pipelines:
- example-http-upload
pipelines: pipelines:
- id: example-http-upload - id: example-http-upload
source: source:
backend: http_upload backend: http_upload
token_env: DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN
destinations: destinations:
- id: local-archive - id: local-archive
backend: local backend: local
@@ -21,4 +25,3 @@ pipelines:
publish: publish:
source: true source: true
html: false html: false

View 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

View File

@@ -23,6 +23,13 @@ func main() {
if len(os.Args) > 1 { if len(os.Args) > 1 {
bundleRoot = 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") idempotencyKey := os.Getenv("DISTRIBUTOR_EXAMPLE_UPLOAD_IDEMPOTENCY_KEY")
client, err := upload.NewClient(upload.ClientOptions{ client, err := upload.NewClient(upload.ClientOptions{
@@ -35,7 +42,10 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
opts := upload.UploadBundleOptions{Root: bundleRoot} opts := upload.UploadBundleOptions{
PipelineID: pipelineID,
Root: bundleRoot,
}
if idempotencyKey != "" { if idempotencyKey != "" {
opts.IdempotencyKey = idempotencyKey opts.IdempotencyKey = idempotencyKey
} }

View File

@@ -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 { 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 { if err := ctx.Err(); err != nil {
return err return err
} }
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) targets, err := targetsFunc()
if err != nil { if err != nil {
return err return err
} }
@@ -213,20 +225,20 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma
return err return err
} }
if nativePath == b.root { 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) info, err := os.Lstat(nativePath)
if err != nil { if err != nil {
if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) { if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) {
continue continue
} }
return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err) return b.translateError(op, logicalPath, err)
} }
if info.IsDir() { 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 { if err := os.Remove(nativePath); err != nil {
return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err) return b.translateError(op, logicalPath, err)
} }
if opts.PruneEmptyDirs { if opts.PruneEmptyDirs {
b.pruneEmptyParents(filepath.Dir(nativePath)) b.pruneEmptyParents(filepath.Dir(nativePath))

View File

@@ -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 { 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 { if err := ctx.Err(); err != nil {
return err return err
} }
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) targets, err := targetsFunc()
if err != nil { if err != nil {
return err return err
} }
for _, target := range targets { 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 return err
} }
} }

View File

@@ -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)) 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 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) return storage.Entry{}, b.translateError(storage.OpWriteFrom, logicalPath, err)
} }
cleanup = false cleanup = false
@@ -178,6 +178,27 @@ func (b *Backend) WriteFrom(ctx context.Context, logicalPath string, r io.Reader
return b.Stat(ctx, logicalPath) 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) { func (b *Backend) Stat(ctx context.Context, logicalPath string) (storage.Entry, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return storage.Entry{}, err 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 { 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 { if err := ctx.Err(); err != nil {
return err return err
} }
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) targets, err := targetsFunc()
if err != nil { if err != nil {
return err return err
} }
@@ -236,20 +269,20 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma
return err return err
} }
if nativePath == b.root { 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) info, err := b.client.Lstat(nativePath)
if err != nil { if err != nil {
if opts.IgnoreMissing && isNotExist(err) { if opts.IgnoreMissing && isNotExist(err) {
continue continue
} }
return b.translateError(storage.OpDeleteManagedBundle, target, err) return b.translateError(op, target, err)
} }
if info.IsDir() { 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 { if err := b.client.Remove(nativePath); err != nil {
return b.translateError(storage.OpDeleteManagedBundle, target, err) return b.translateError(op, target, err)
} }
if opts.PruneEmptyDirs { if opts.PruneEmptyDirs {
b.pruneEmptyParents(parentOf(target)) 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) 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 { func (b *Backend) translateError(op, logicalPath string, err error) error {
kind := storage.ErrUnknown kind := storage.ErrUnknown
switch { switch {

View 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
}

328
internal/app/prune.go Normal file
View 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
View 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)
}

View 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
}

View 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, ",")
}

View File

@@ -69,8 +69,8 @@ func processDestinationSelection(ctx context.Context, request runDestinationRequ
Publish: *request.destination.Publish, Publish: *request.destination.Publish,
Transform: request.destination.Transform, Transform: request.destination.Transform,
Links: request.destination.Links, Links: request.destination.Links,
Workflow: request.destination.Workflow,
Transformers: request.transforms, Transformers: request.transforms,
Transfer: request.destination.Transfer,
DistributorVersion: Version, DistributorVersion: Version,
Force: request.options.Force, Force: request.options.Force,
} }
@@ -80,8 +80,8 @@ func processDestinationSelection(ctx context.Context, request runDestinationRequ
} }
if isFixedPathDestination(request.destination) { if isFixedPathDestination(request.destination) {
plan.PathMapping = config.PathMappingFixed plan.PathMapping = config.PathMappingFixed
if request.options.DryRun && isDestructiveFixedPathAction(plan.Action) { if request.options.DryRun && isFixedPathWorkflowAction(plan.Action) {
warning := fixedPathReplacementWarning(plan) warning := fixedPathWorkflowWarning(plan)
request.recorder.addPipelineWarning(request.pipelineIndex, warning) request.recorder.addPipelineWarning(request.pipelineIndex, warning)
} }
} }
@@ -139,6 +139,7 @@ func (recorder *runReportRecorder) recordDestinationFailure(pipelineIndex int, f
recorder.failures.add(failure.pipelineID, failure.destinationID, failure.backend, storage.DisplayPath(failure.bundlePath), failure.err) recorder.failures.add(failure.pipelineID, failure.destinationID, failure.backend, storage.DisplayPath(failure.bundlePath), failure.err)
recorder.summary.recordFailure() recorder.summary.recordFailure()
if includeAction { if includeAction {
recorder.summary.recordFailureAction(action.Action)
recorder.addPipelineAction(pipelineIndex, action) recorder.addPipelineAction(pipelineIndex, action)
} }
} }
@@ -159,5 +160,8 @@ func completePlanIdentity(plan publish.Plan, pipeline config.Pipeline, destinati
if plan.DestinationBundlePath == "" { if plan.DestinationBundlePath == "" {
plan.DestinationBundlePath = selection.DestinationBundlePath plan.DestinationBundlePath = selection.DestinationBundlePath
} }
if plan.Workflow == "" {
plan.Workflow = destination.Workflow
}
return plan return plan
} }

View File

@@ -6,20 +6,19 @@ import (
) )
func shouldNotify(action publish.Action) bool { 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 { func notifyEvent(plan publish.Plan) notify.Event {
outputs := make([]notify.Output, 0, len(plan.Outputs)) outputs := make([]notify.Output, 0, len(plan.Outputs))
for _, output := range plan.Outputs { for _, output := range plan.Outputs {
stateOutput := output.StateOutputFile()
outputs = append(outputs, notify.Output{ outputs = append(outputs, notify.Output{
Path: stateOutput.Path, Path: output.DestinationPath,
Kind: stateOutput.Kind, Kind: output.Kind,
SourcePath: stateOutput.SourcePath, SourcePath: output.SourcePath,
Transform: stateOutput.Transform, Transform: output.Transform,
SHA256: stateOutput.SHA256, SHA256: output.SHA256,
Size: stateOutput.Size, Size: output.Size,
}) })
} }
return notify.Event{ return notify.Event{

View File

@@ -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) fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", action.BundlePath, destinationID, action.Backend, pathMappingRecordSummary(action), action.Reason)
return return
} }
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", 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 { func pathMappingRecordSummary(action RunActionRecord) string {
@@ -70,6 +70,13 @@ func pathMappingRecordSummary(action RunActionRecord) string {
return fmt.Sprintf(" path_mapping=fixed target=%s", action.DestinationPath) 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 { func outputRecordSummary(outputs []RunOutputRecord) string {
if len(outputs) == 0 { if len(outputs) == 0 {
return "none" return "none"
@@ -136,6 +143,7 @@ type RunActionRecord struct {
BundlePath string `json:"bundle_path"` BundlePath string `json:"bundle_path"`
DestinationPath string `json:"destination_path"` DestinationPath string `json:"destination_path"`
PathMapping string `json:"path_mapping,omitempty"` PathMapping string `json:"path_mapping,omitempty"`
Workflow string `json:"workflow,omitempty"`
Action string `json:"action"` Action string `json:"action"`
PrimaryURL string `json:"primary_url,omitempty"` PrimaryURL string `json:"primary_url,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
@@ -158,6 +166,13 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActi
if destinationID == "" { if destinationID == "" {
destinationID = "unknown" destinationID = "unknown"
} }
action := "error"
outputs := []RunOutputRecord{}
switch plan.Action {
case publish.ActionFailUnmanaged, publish.ActionFailConflict:
action = string(plan.Action)
outputs = runOutputsFromPlan(plan.Outputs)
}
return RunActionRecord{ return RunActionRecord{
PipelineID: plan.PipelineID, PipelineID: plan.PipelineID,
DestinationID: destinationID, DestinationID: destinationID,
@@ -166,10 +181,11 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActi
BundlePath: storage.DisplayPath(plan.BundlePath), BundlePath: storage.DisplayPath(plan.BundlePath),
DestinationPath: storage.DisplayPath(plan.DestinationBundlePath), DestinationPath: storage.DisplayPath(plan.DestinationBundlePath),
PathMapping: plan.PathMapping, PathMapping: plan.PathMapping,
Action: "error", Workflow: plan.Workflow,
Action: action,
PrimaryURL: plan.PrimaryURL, PrimaryURL: plan.PrimaryURL,
Reason: planErr.Error(), Reason: planErr.Error(),
Outputs: []RunOutputRecord{}, Outputs: outputs,
} }
} }
return RunActionRecord{ return RunActionRecord{
@@ -180,6 +196,7 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActi
BundlePath: storage.DisplayPath(plan.BundlePath), BundlePath: storage.DisplayPath(plan.BundlePath),
DestinationPath: storage.DisplayPath(plan.DestinationBundlePath), DestinationPath: storage.DisplayPath(plan.DestinationBundlePath),
PathMapping: plan.PathMapping, PathMapping: plan.PathMapping,
Workflow: plan.Workflow,
Action: string(plan.Action), Action: string(plan.Action),
PrimaryURL: plan.PrimaryURL, PrimaryURL: plan.PrimaryURL,
Reason: plan.Reason, Reason: plan.Reason,
@@ -203,15 +220,14 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro
func runOutputsFromPlan(outputs []publish.Output) []RunOutputRecord { func runOutputsFromPlan(outputs []publish.Output) []RunOutputRecord {
results := make([]RunOutputRecord, 0, len(outputs)) results := make([]RunOutputRecord, 0, len(outputs))
for _, output := range outputs { for _, output := range outputs {
stateOutput := output.StateOutputFile()
results = append(results, RunOutputRecord{ results = append(results, RunOutputRecord{
Path: stateOutput.Path, Path: output.DestinationPath,
Kind: stateOutput.Kind, Kind: output.Kind,
SourcePath: stateOutput.SourcePath, SourcePath: output.SourcePath,
Transform: stateOutput.Transform, Transform: output.Transform,
URL: stateOutput.URL, URL: output.URL,
SHA256: stateOutput.SHA256, SHA256: output.SHA256,
Size: stateOutput.Size, Size: output.Size,
}) })
} }
return results return results

View File

@@ -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)} 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 { func isFixedPathWorkflowAction(action publish.Action) bool {
return action == publish.ActionReplaceOlder || action == publish.ActionForceReplace return action == publish.ActionUpsertAdditive || action == publish.ActionReplaceCatalog || action == publish.ActionForceReplace
} }
func fixedPathReplacementWarning(plan publish.Plan) OutputWarning { func fixedPathWorkflowWarning(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))} 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 { func destinationIDs(destinations []config.Destination) []string {

View File

@@ -7,14 +7,17 @@ import (
) )
type runSummary struct { type runSummary struct {
dryRun bool dryRun bool
planned int planned int
publishNew int publishNew int
replaceOlder int upsertAdditive int
forceReplace int replaceCatalog int
skipped int skipSame int
failures int forceReplace int
fixedPath int failUnmanaged int
failConflict int
failures int
fixedPath int
} }
func (s *runSummary) recordPlan(action publish.Action) { func (s *runSummary) recordPlan(action publish.Action) {
@@ -22,12 +25,23 @@ func (s *runSummary) recordPlan(action publish.Action) {
switch action { switch action {
case publish.ActionPublishNew: case publish.ActionPublishNew:
s.publishNew++ s.publishNew++
case publish.ActionReplaceOlder: case publish.ActionUpsertAdditive:
s.replaceOlder++ s.upsertAdditive++
case publish.ActionReplaceCatalog:
s.replaceCatalog++
case publish.ActionForceReplace: case publish.ActionForceReplace:
s.forceReplace++ s.forceReplace++
case publish.ActionSkipSame, publish.ActionSkipDestinationNewer: case publish.ActionSkipSame:
s.skipped++ 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 { type RunSummaryCounters struct {
Status string `json:"status"` Status string `json:"status"`
Planned int `json:"planned"` Planned int `json:"planned"`
PublishNew int `json:"publish_new"` PublishNew int `json:"publish_new"`
ReplaceOlder int `json:"replace_older"` UpsertAdditive int `json:"upsert_additive"`
ForceReplace int `json:"force_replace"` ReplaceCatalog int `json:"replace_catalog"`
Skipped int `json:"skipped"` SkipSame int `json:"skip_same"`
Failed int `json:"failed"` ForceReplace int `json:"force_replace"`
DryRun bool `json:"dry_run"` FailUnmanaged int `json:"fail_unmanaged"`
FixedPath int `json:"fixed_path"` FailConflict int `json:"fail_conflict"`
Failed int `json:"failed"`
DryRun bool `json:"dry_run"`
FixedPath int `json:"fixed_path"`
} }
func (s RunSummaryCounters) Line() string { 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 { func (s runSummary) Result() RunSummaryCounters {
@@ -61,14 +78,17 @@ func (s runSummary) Result() RunSummaryCounters {
status = "failed" status = "failed"
} }
return RunSummaryCounters{ return RunSummaryCounters{
Status: status, Status: status,
Planned: s.planned, Planned: s.planned,
PublishNew: s.publishNew, PublishNew: s.publishNew,
ReplaceOlder: s.replaceOlder, UpsertAdditive: s.upsertAdditive,
ForceReplace: s.forceReplace, ReplaceCatalog: s.replaceCatalog,
Skipped: s.skipped, SkipSame: s.skipSame,
Failed: s.failures, ForceReplace: s.forceReplace,
DryRun: s.dryRun, FailUnmanaged: s.failUnmanaged,
FixedPath: s.fixedPath, FailConflict: s.failConflict,
Failed: s.failures,
DryRun: s.dryRun,
FixedPath: s.fixedPath,
} }
} }

View File

@@ -41,8 +41,8 @@ func TestRunDryRunPrintsConfigSummary(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"Configured pipelines: 1", "Configured pipelines: 1",
"- pipeline=reports source=local bundles=1 destinations=archive", "- pipeline=reports source=local bundles=1 destinations=archive",
"bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt", "bundle=. destination=archive backend=local action=publish_new workflow=additive 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", "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) { if !strings.Contains(output, want) {
t.Fatalf("Run() output = %q, want substring %q", 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" { if destinationState.PipelineID != "reports" || destinationState.DestinationID != "archive" {
t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID) t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID)
} }
if destinationState.Source.Manifest.ID != manifest.ID { if destinationState.SourceID != manifest.ID {
t.Fatalf("state source id = %q, want %q", destinationState.Source.Manifest.ID, manifest.ID) t.Fatalf("state source id = %q, want %q", destinationState.SourceID, manifest.ID)
} }
if got, want := len(destinationState.Outputs), 2; got != want { if got, want := len(destinationState.Outputs), 2; got != want {
t.Fatalf("state output count = %d, want %d", got, want) t.Fatalf("state output count = %d, want %d", got, want)
} }
if destinationState.Links != nil || destinationState.Outputs[0].URL != "" { if destinationState.PrimaryURL != "" || destinationState.Outputs[0].URL != "" {
t.Fatalf("state links = %#v output URL=%q, want absent", destinationState.Links, 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", ID: "reports",
Source: config.Backend{ Source: config.Backend{
Backend: config.BackendHTTPUpload, Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{TokenEnv: "UPLOAD_TOKEN"},
}, },
Destinations: []config.Destination{ 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) config.ApplyDefaults(&cfg)
provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{ provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{
@@ -358,8 +362,8 @@ func TestRunRecordsLinksForNestedBundlePath(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
destinationState := readStateFile(t, filepath.Join(destinationRoot, "daily", "brentwood", storage.StateFileName)) 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" { if destinationState.PrimaryURL != "https://reports.example.com/archive/daily/brentwood/report.md" {
t.Fatalf("state links = %#v, want source primary URL", destinationState.Links) t.Fatalf("state primary URL = %q, want source primary URL", destinationState.PrimaryURL)
} }
outputs := outputsByPath(destinationState.Outputs) outputs := outputsByPath(destinationState.Outputs)
if outputs["report.md"].URL != "https://reports.example.com/archive/daily/brentwood/report.md" { 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) t.Fatalf("Run() error = %v", err)
} }
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName)) destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
if destinationState.Links == nil || destinationState.Links.PrimaryURL != "https://reports.example.com/latest/" { if destinationState.PrimaryURL != "https://reports.example.com/latest/" {
t.Fatalf("state links = %#v, want fixed index primary URL", destinationState.Links) t.Fatalf("state primary URL = %q, want fixed index primary URL", destinationState.PrimaryURL)
} }
if got, want := len(destinationState.Outputs), 1; got != want { if got, want := len(destinationState.Outputs), 1; got != want {
t.Fatalf("state output count = %d, want %d", 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) t.Fatalf("nested new report stat error = %v, want not exist", err)
} }
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName)) destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
if destinationState.Source.Manifest.ID != "reports.new" { if destinationState.SourceID != "reports.new" {
t.Fatalf("state source id = %q, want reports.new", destinationState.Source.Manifest.ID) 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) t.Fatalf("Run() error = %v", err)
} }
destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName)) destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName))
if destinationState.Source.Manifest.ID != "reports.a" { if destinationState.SourceID != "reports.a" {
t.Fatalf("state source id = %q, want reports.a", destinationState.Source.Manifest.ID) 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"}, {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 { if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
t.Fatalf("first Run() error = %v", err) t.Fatalf("first Run() error = %v", err)
} }
@@ -526,8 +530,9 @@ func TestRunFixedPathDryRunWarnsForReplacement(t *testing.T) {
} }
output := stdout.String() output := stdout.String()
for _, want := range []string{ for _, want := range []string{
"Warning: pipeline=reports destination=archive path_mapping=fixed action=replace_older replaces destination root for selected_bundle=new", "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_older", "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) { if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", 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") testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nOld.\n")
} }
func TestRunFixedPathReplacesOlderManagedState(t *testing.T) { func TestRunJSONIncludesWorkflowActionAndSummary(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "old", testBundleOptions{ writeSourceBundle(t, sourceRoot, "old", testBundleOptions{
@@ -547,12 +552,10 @@ func TestRunFixedPathReplacesOlderManagedState(t *testing.T) {
{Path: "summary.txt", Data: "Old summary\n"}, {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 { if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
t.Fatalf("first Run() error = %v", err) t.Fatalf("first Run() error = %v", err)
} }
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nOld.\n")
writeSourceBundle(t, sourceRoot, "new", testBundleOptions{ writeSourceBundle(t, sourceRoot, "new", testBundleOptions{
ID: "reports.new", ID: "reports.new",
Created: testutil.DefaultCreated.Add(time.Hour), 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 var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{ err := Run(context.Background(), RunOptions{
ConfigPath: testutil.WriteLocalConfigWithPathMapping(t, sourceRoot, destinationRoot, config.PathMappingFixed), ConfigPath: configPath,
Stdout: &stdout, DryRun: true,
Stdout: &stdout,
OutputFormat: OutputFormatJSON,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(stdout.String(), "action=skip_destination_newer") { result := decodeAppResult(t, stdout.String())
t.Fatalf("stdout = %q, want skip_destination_newer", 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) { 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) { if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
t.Fatalf("unmanaged stat error = %v, want removed", 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) { func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
@@ -655,14 +653,6 @@ func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
{Path: "summary.txt", Data: "Old summary\n"}, {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() s3Destination := fake.New()
sshDestination := fake.New() sshDestination := fake.New()
cfg := config.Config{Pipelines: []config.Pipeline{{ 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 { if err := runConfigWithBackendFactory(context.Background(), cfg, RunOptions{}, provider); err != nil {
t.Fatalf("Run() error = %v", err) 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, "report.md", "# Report\nNew.\n")
testutil.AssertFakeFile(t, s3Destination, "summary.txt", "New summary\n") testutil.AssertFakeFile(t, s3Destination, "summary.txt", "New summary\n")
testutil.AssertFakeMissing(t, s3Destination, "new/report.md") testutil.AssertFakeMissing(t, s3Destination, "new/report.md")
@@ -762,17 +776,22 @@ func TestRunNotifiesGeneratedOutputMetadata(t *testing.T) {
func TestRunNotifiesAfterReplacement(t *testing.T) { func TestRunNotifiesAfterReplacement(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
older := manifest configPath := writeLocalConfigWithWorkflow(t, sourceRoot, destinationRoot, config.PathMappingPreserveRelative, config.WorkflowReplacement)
older.Created = older.Created.Add(-time.Hour) if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
writeDestinationState(t, destinationRoot, "", older) t.Fatalf("first Run() error = %v", err)
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{
Created: testutil.DefaultCreated.Add(time.Hour),
Files: []testFile{
{Path: "report.md", Data: "# Report\nNew.\n"},
{Path: "summary.txt", Data: "New summary\n"},
},
})
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
err := Run(context.Background(), RunOptions{ err := Run(context.Background(), RunOptions{
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot), ConfigPath: configPath,
Notifier: notifier, Notifier: notifier,
}) })
if err != nil { if err != nil {
@@ -781,8 +800,8 @@ func TestRunNotifiesAfterReplacement(t *testing.T) {
if got, want := len(notifier.events), 1; got != want { if got, want := len(notifier.events), 1; got != want {
t.Fatalf("notification count = %d, want %d", got, want) t.Fatalf("notification count = %d, want %d", got, want)
} }
if notifier.events[0].Action != "replace_older" { if notifier.events[0].Action != "replace_catalog" {
t.Fatalf("notification action = %q, want replace_older", notifier.events[0].Action) t.Fatalf("notification action = %q, want replace_catalog", notifier.events[0].Action)
} }
} }
@@ -889,8 +908,8 @@ func TestBuildRunReportIncludesPartialFailures(t *testing.T) {
firstDestination := t.TempDir() firstDestination := t.TempDir()
secondDestination := t.TempDir() secondDestination := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(firstDestination, "report.md"), []byte("data"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err) t.Fatalf("write unmanaged planned file: %v", err)
} }
cfg, err := config.LoadFile(writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination)) cfg, err := config.LoadFile(writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination))
if err != nil { if err != nil {
@@ -907,8 +926,8 @@ func TestBuildRunReportIncludesPartialFailures(t *testing.T) {
if got, want := len(report.Actions), 2; got != want { if got, want := len(report.Actions), 2; got != want {
t.Fatalf("action count = %d, want %d", 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") { 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 error", report.Actions[0]) 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" { 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]) t.Fatalf("second action = %#v, want archive-two publish_new", report.Actions[1])
@@ -1043,7 +1062,7 @@ func TestRunStillRunsAllConfiguredPipelines(t *testing.T) {
testutil.AssertFile(t, filepath.Join(secondDestination, "report.md"), "# Report\nSunny.\n") testutil.AssertFile(t, filepath.Join(secondDestination, "report.md"), "# Report\nSunny.\n")
} }
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) { func TestRunNotifiesForAdditiveUpsert(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
@@ -1051,14 +1070,24 @@ func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil { if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
t.Fatalf("first Run() error = %v", err) 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{} notifier := &recordingNotifier{}
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Notifier: notifier}) err := Run(context.Background(), RunOptions{ConfigPath: configPath, Notifier: notifier})
if err != nil { if err != nil {
t.Fatalf("second Run() error = %v", err) t.Fatalf("second Run() error = %v", err)
} }
if len(notifier.events) != 0 { if got, want := len(notifier.events), 1; got != want {
t.Fatalf("notifications = %#v, want none", notifier.events) 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)
} }
} }
@@ -1089,8 +1118,8 @@ func TestRunContinuesAfterDestinationFailure(t *testing.T) {
firstDestination := t.TempDir() firstDestination := t.TempDir()
secondDestination := t.TempDir() secondDestination := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(firstDestination, "report.md"), []byte("data"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err) t.Fatalf("write unmanaged planned file: %v", err)
} }
var stdout bytes.Buffer var stdout bytes.Buffer
@@ -1106,9 +1135,9 @@ func TestRunContinuesAfterDestinationFailure(t *testing.T) {
} }
output := stdout.String() output := stdout.String()
for _, want := range []string{ for _, want := range []string{
"destination=archive-one backend=local action=error", "destination=archive-one backend=local action=fail_unmanaged workflow=additive",
"destination=archive-two backend=local action=publish_new", "destination=archive-two backend=local action=publish_new workflow=additive",
"Final status: failed planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=1 dry_run=false", "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) { if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want) t.Fatalf("stdout = %q, want substring %q", output, want)
@@ -1358,6 +1387,55 @@ func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil { if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
t.Fatalf("first Run() error = %v", err) 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 var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Stdout: &stdout}) err := Run(context.Background(), RunOptions{ConfigPath: configPath, Stdout: &stdout})
@@ -1369,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) { func TestRunFailsOnUnmanagedDestination(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
@@ -1460,10 +1481,50 @@ func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
if !strings.Contains(stdout.String(), "action=force_replace") { if !strings.Contains(stdout.String(), "action=force_replace") {
t.Fatalf("stdout = %q, want force_replace", stdout.String()) 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) { if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
t.Fatalf("unmanaged file stat error = %v, want not exist", err) t.Fatalf("unmanaged file stat error = %v, want not exist", err)
} }
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n") 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) { func TestRunFansOutToLocalDestinations(t *testing.T) {
@@ -1536,7 +1597,7 @@ func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
"pipeline=local-to-ssh source=local", "pipeline=local-to-ssh source=local",
"destination=ssh-archive backend=ssh action=publish_new", "destination=ssh-archive backend=ssh action=publish_new",
"pipeline=ssh-to-local source=ssh", "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) { if !strings.Contains(dryRunOutput.String(), want) {
t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want) t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want)
@@ -1609,6 +1670,12 @@ func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
testutil.AssertFakeFile(t, sshDestination, "bundle/report.md", "# Report\nSunny.\n") testutil.AssertFakeFile(t, sshDestination, "bundle/report.md", "# Report\nSunny.\n")
testutil.AssertFakeMissing(t, sshDestination, "bundle/old.txt") testutil.AssertFakeMissing(t, sshDestination, "bundle/old.txt")
testutil.AssertFakeFile(t, sshDestination, "bundle-sibling/keep.txt", "keep") 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) { func TestRunDryRunDoesNotWrite(t *testing.T) {
@@ -1666,6 +1733,24 @@ func writeLocalConfig(t *testing.T, sourceRoot, destinationRoot string) string {
return testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot) 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 { func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestination string) string {
t.Helper() t.Helper()
return testutil.WriteFanoutLocalConfig(t, sourceRoot, firstDestination, secondDestination) return testutil.WriteFanoutLocalConfig(t, sourceRoot, firstDestination, secondDestination)
@@ -1674,11 +1759,15 @@ func writeFanoutConfig(t *testing.T, sourceRoot, firstDestination, secondDestina
func writeUploadPipelineConfig(t *testing.T, destinationRoot string) string { func writeUploadPipelineConfig(t *testing.T, destinationRoot string) string {
t.Helper() t.Helper()
return writeConfigFile(t, ` return writeConfigFile(t, `
upload_tokens:
- id: reporter
token_env: UPLOAD_TOKEN
allow_pipelines:
- reports
pipelines: pipelines:
- id: reports - id: reports
source: source:
backend: http_upload backend: http_upload
token_env: UPLOAD_TOKEN
destinations: destinations:
- id: archive - id: archive
backend: local backend: local
@@ -1718,11 +1807,6 @@ func writeConfigFile(t *testing.T, body string) string {
return path 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) { func writeJSONManifest(t *testing.T, root string, manifest bundle.Manifest) {
t.Helper() t.Helper()
if err := os.MkdirAll(root, 0o755); err != nil { if err := os.MkdirAll(root, 0o755); err != nil {
@@ -1737,13 +1821,93 @@ func writeJSONManifest(t *testing.T, root string, manifest bundle.Manifest) {
} }
} }
func readStateFile(t *testing.T, path string) state.DistributorState { type testDestinationState struct {
t.Helper() PipelineID string
return testutil.ReadDestinationState(t, path) DestinationID string
SourceID string
SourceDigest string
SourceCreated time.Time
PrimaryURL string
Outputs []testStateOutput
} }
func outputsByPath(outputs []state.OutputFile) map[string]state.OutputFile { type testStateOutput struct {
byPath := make(map[string]state.OutputFile, len(outputs)) 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 { for _, output := range outputs {
byPath[output.Path] = output byPath[output.Path] = output
} }

View File

@@ -70,14 +70,24 @@ func writeServeUploadConfig(t *testing.T, tokenEnvs []string) string {
server: server:
http: http:
bind: 127.0.0.1:0 bind: 127.0.0.1:0
pipelines: upload_tokens:
` `
for index, tokenEnv := range tokenEnvs { for index, tokenEnv := range tokenEnvs {
body += ` 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)) + ` - id: reports-` + string(rune('a'+index)) + `
source: source:
backend: http_upload backend: http_upload
token_env: ` + tokenEnv + `
destinations: destinations:
- id: archive - id: archive
backend: local backend: local

View File

@@ -44,6 +44,7 @@ type UploadRunRecord struct {
} }
type UploadRequest struct { type UploadRequest struct {
TokenID string
PipelineID string PipelineID string
ContentType string ContentType string
Body io.Reader Body io.Reader
@@ -115,6 +116,7 @@ type uploadJob struct {
} }
type uploadIdempotencyScope struct { type uploadIdempotencyScope struct {
TokenID string
PipelineID string PipelineID string
Key string Key string
} }
@@ -200,7 +202,7 @@ func (coordinator *UploadCoordinator) Submit(ctx context.Context, request Upload
if err := ingest.ValidateContentType(request.ContentType); err != nil { if err := ingest.ValidateContentType(request.ContentType); err != nil {
return UploadRunRecord{}, err return UploadRunRecord{}, err
} }
scope, hasKey := uploadRequestIdempotencyScope(pipeline.ID, request.IdempotencyKey) scope, hasKey := uploadRequestIdempotencyScope(request.TokenID, pipeline.ID, request.IdempotencyKey)
coordinator.mu.Lock() coordinator.mu.Lock()
coordinator.expireLocked(coordinator.now().UTC()) coordinator.expireLocked(coordinator.now().UTC())
@@ -286,11 +288,11 @@ func (coordinator *UploadCoordinator) Submit(ctx context.Context, request Upload
return record, nil return record, nil
} }
func uploadRequestIdempotencyScope(pipelineID, key string) (uploadIdempotencyScope, bool) { func uploadRequestIdempotencyScope(tokenID, pipelineID, key string) (uploadIdempotencyScope, bool) {
if key == "" { if key == "" {
return uploadIdempotencyScope{}, false return uploadIdempotencyScope{}, false
} }
return uploadIdempotencyScope{PipelineID: pipelineID, Key: key}, true return uploadIdempotencyScope{TokenID: tokenID, PipelineID: pipelineID, Key: key}, true
} }
func (coordinator *UploadCoordinator) Status(runID UploadRunID) (UploadRunRecord, bool) { func (coordinator *UploadCoordinator) Status(runID UploadRunID) (UploadRunRecord, bool) {

View File

@@ -271,6 +271,7 @@ func TestUploadCoordinatorIdempotencyReturnsOriginalRunForSameManifest(t *testin
}) })
first, err := coordinator.Submit(context.Background(), UploadRequest{ first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"), Body: strings.NewReader("same"),
@@ -282,6 +283,7 @@ func TestUploadCoordinatorIdempotencyReturnsOriginalRunForSameManifest(t *testin
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded) waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
second, err := coordinator.Submit(context.Background(), UploadRequest{ second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"), Body: strings.NewReader("same"),
@@ -310,6 +312,7 @@ func TestUploadCoordinatorIdempotencyConflictsForDifferentManifest(t *testing.T)
}) })
first, err := coordinator.Submit(context.Background(), UploadRequest{ first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("one"), Body: strings.NewReader("one"),
@@ -321,6 +324,7 @@ func TestUploadCoordinatorIdempotencyConflictsForDifferentManifest(t *testing.T)
waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded) waitForUploadStatus(t, coordinator, first.ID, UploadStatusSucceeded)
_, err = coordinator.Submit(context.Background(), UploadRequest{ _, err = coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("two"), Body: strings.NewReader("two"),
@@ -331,6 +335,42 @@ func TestUploadCoordinatorIdempotencyConflictsForDifferentManifest(t *testing.T)
} }
} }
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) { func TestUploadCoordinatorIdempotencyIsScopedByPipeline(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -343,6 +383,7 @@ func TestUploadCoordinatorIdempotencyIsScopedByPipeline(t *testing.T) {
}) })
first, err := coordinator.Submit(context.Background(), UploadRequest{ first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports-one", PipelineID: "reports-one",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("one"), Body: strings.NewReader("one"),
@@ -352,6 +393,7 @@ func TestUploadCoordinatorIdempotencyIsScopedByPipeline(t *testing.T) {
t.Fatalf("first Submit() error = %v", err) t.Fatalf("first Submit() error = %v", err)
} }
second, err := coordinator.Submit(context.Background(), UploadRequest{ second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports-two", PipelineID: "reports-two",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("two"), Body: strings.NewReader("two"),
@@ -408,6 +450,7 @@ func TestUploadCoordinatorIdempotencyReturnsRetryableConflictWhileStaging(t *tes
firstErr := make(chan error, 1) firstErr := make(chan error, 1)
go func() { go func() {
_, err := coordinator.Submit(context.Background(), UploadRequest{ _, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"), Body: strings.NewReader("same"),
@@ -419,6 +462,7 @@ func TestUploadCoordinatorIdempotencyReturnsRetryableConflictWhileStaging(t *tes
var reads atomic.Int64 var reads atomic.Int64
_, err := coordinator.Submit(context.Background(), UploadRequest{ _, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: readerFunc(func(data []byte) (int, error) { Body: readerFunc(func(data []byte) (int, error) {
@@ -440,6 +484,57 @@ func TestUploadCoordinatorIdempotencyReturnsRetryableConflictWhileStaging(t *tes
} }
} }
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) { func TestUploadCoordinatorIdempotencyExpiresWithCompletedStatus(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -455,6 +550,7 @@ func TestUploadCoordinatorIdempotencyExpiresWithCompletedStatus(t *testing.T) {
}) })
first, err := coordinator.Submit(context.Background(), UploadRequest{ first, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"), Body: strings.NewReader("same"),
@@ -469,6 +565,7 @@ func TestUploadCoordinatorIdempotencyExpiresWithCompletedStatus(t *testing.T) {
coordinator.Expire() coordinator.Expire()
second, err := coordinator.Submit(context.Background(), UploadRequest{ second, err := coordinator.Submit(context.Background(), UploadRequest{
TokenID: "reporter-a",
PipelineID: "reports", PipelineID: "reports",
ContentType: ingest.ContentTypeTar, ContentType: ingest.ContentTypeTar,
Body: strings.NewReader("same"), Body: strings.NewReader("same"),
@@ -564,11 +661,11 @@ func uploadCoordinatorConfig(t *testing.T, opts uploadCoordinatorConfigOptions)
}}, }},
} }
for _, pipelineID := range opts.pipelineIDs { for _, pipelineID := range opts.pipelineIDs {
tokenEnv := strings.ToUpper(strings.ReplaceAll(pipelineID, "-", "_")) + "_TOKEN"
cfg.Pipelines = append(cfg.Pipelines, config.Pipeline{ cfg.Pipelines = append(cfg.Pipelines, config.Pipeline{
ID: pipelineID, ID: pipelineID,
Source: config.Backend{ Source: config.Backend{
Backend: config.BackendHTTPUpload, Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{TokenEnv: strings.ToUpper(strings.ReplaceAll(pipelineID, "-", "_")) + "_TOKEN"},
}, },
Destinations: []config.Destination{{ Destinations: []config.Destination{{
ID: "archive", ID: "archive",
@@ -576,6 +673,11 @@ func uploadCoordinatorConfig(t *testing.T, opts uploadCoordinatorConfigOptions)
Path: t.TempDir(), Path: t.TempDir(),
}}, }},
}) })
cfg.UploadTokens = append(cfg.UploadTokens, config.UploadToken{
ID: pipelineID + "-reporter",
TokenEnv: tokenEnv,
AllowPipelines: []string{pipelineID},
})
} }
return cfg return cfg
} }

View File

@@ -19,8 +19,15 @@ type uploadCoordinator interface {
} }
type uploadHTTPHandler struct { type uploadHTTPHandler struct {
coordinator uploadCoordinator coordinator uploadCoordinator
tokens map[string]string tokens map[string]resolvedUploadToken
uploadPipelines map[string]struct{}
}
type resolvedUploadToken struct {
ID string
Value string
AllowedPipelines map[string]struct{}
} }
type uploadAcceptedResponse struct { type uploadAcceptedResponse struct {
@@ -42,38 +49,57 @@ func newUploadHTTPHandler(ctx context.Context, cfg config.Config, environment co
return nil, err return nil, err
} }
return uploadHTTPHandler{ return uploadHTTPHandler{
coordinator: NewUploadCoordinator(ctx, cfg), coordinator: NewUploadCoordinator(ctx, cfg),
tokens: tokens, tokens: tokens,
uploadPipelines: uploadPipelineSet(cfg),
}, nil }, nil
} }
func resolveUploadTokens(cfg config.Config, environment config.Environment) (map[string]string, error) { func resolveUploadTokens(cfg config.Config, environment config.Environment) (map[string]resolvedUploadToken, error) {
tokens := make(map[string]string) tokens := make(map[string]resolvedUploadToken)
for _, pipeline := range cfg.Pipelines { for _, uploadToken := range cfg.UploadTokens {
if pipeline.Source.Backend != config.BackendHTTPUpload { token, ok := environment.Lookup(uploadToken.TokenEnv)
continue
}
tokenName := pipeline.Source.Upload.TokenEnv
token, ok := environment.Lookup(tokenName)
if !ok { if !ok {
return 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 == "" { if token == "" {
return 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 { if existing, exists := tokens[token]; exists {
return 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
} }
return tokens, 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) { func (handler uploadHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/healthz": case r.Method == http.MethodGet && r.URL.Path == "/healthz":
handler.handleHealth(w) 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) handler.handleUpload(w, r)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/runs/"): case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/runs/"):
handler.handleRunStatus(w, r) handler.handleRunStatus(w, r)
@@ -91,11 +117,28 @@ func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Req
writeHTTPError(w, http.StatusBadRequest, "pipeline id is not accepted") writeHTTPError(w, http.StatusBadRequest, "pipeline id is not accepted")
return 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 { if !ok {
writeHTTPError(w, http.StatusUnauthorized, "unauthorized") writeHTTPError(w, http.StatusUnauthorized, "unauthorized")
return 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") contentType := r.Header.Get("Content-Type")
if err := ingest.ValidateContentType(contentType); err != nil { if err := ingest.ValidateContentType(contentType); err != nil {
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type") writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
@@ -107,6 +150,7 @@ func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Req
return return
} }
record, err := handler.coordinator.Submit(r.Context(), UploadRequest{ record, err := handler.coordinator.Submit(r.Context(), UploadRequest{
TokenID: token.ID,
PipelineID: pipelineID, PipelineID: pipelineID,
ContentType: contentType, ContentType: contentType,
Body: r.Body, Body: r.Body,
@@ -122,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) { func (handler uploadHTTPHandler) handleRunStatus(w http.ResponseWriter, r *http.Request) {
rawRunID := strings.TrimPrefix(r.URL.Path, "/runs/") rawRunID := strings.TrimPrefix(r.URL.Path, "/runs/")
if rawRunID == "" || strings.Contains(rawRunID, "/") { if rawRunID == "" || strings.Contains(rawRunID, "/") {
@@ -136,17 +193,17 @@ func (handler uploadHTTPHandler) handleRunStatus(w http.ResponseWriter, r *http.
writeJSON(w, http.StatusOK, record) writeJSON(w, http.StatusOK, record)
} }
func (handler uploadHTTPHandler) authenticate(header string) (string, bool) { func (handler uploadHTTPHandler) authenticate(header string) (resolvedUploadToken, bool) {
const prefix = "Bearer " const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) { if !strings.HasPrefix(header, prefix) {
return "", false return resolvedUploadToken{}, false
} }
token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if token == "" { if token == "" {
return "", false return resolvedUploadToken{}, false
} }
pipelineID, ok := handler.tokens[token] resolved, ok := handler.tokens[token]
return pipelineID, ok return resolved, ok
} }
func uploadIdempotencyKey(header http.Header) (string, error) { func uploadIdempotencyKey(header http.Header) (string, error) {

View File

@@ -21,6 +21,7 @@ import (
"gitea.maximumdirect.net/eric/distributor/internal/ingest" "gitea.maximumdirect.net/eric/distributor/internal/ingest"
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/testutil" "gitea.maximumdirect.net/eric/distributor/internal/testutil"
clientupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
) )
func TestHTTPUploadPublishesTarAndGzipFanout(t *testing.T) { func TestHTTPUploadPublishesTarAndGzipFanout(t *testing.T) {
@@ -78,15 +79,16 @@ func TestHTTPUploadInvalidArchiveIsRejectedWithoutRunID(t *testing.T) {
destinations: []string{destination}, destinations: []string{destination},
}}, 4, 1)) }}, 4, 1))
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: coordinator, coordinator: coordinator,
tokens: map[string]string{"reports-secret": "reports"}, tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
defer server.Close() defer server.Close()
status, body := postHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("not a tar archive")) status, body := postHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("not a tar archive"))
if status != http.StatusBadRequest { if status != http.StatusBadRequest {
t.Fatalf("POST /upload status = %d, want %d; body = %s", status, http.StatusBadRequest, body) 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") { if strings.Contains(body, "run_id") || strings.Contains(body, "reports-secret") {
t.Fatalf("invalid archive response exposed run id or token: %s", body) t.Fatalf("invalid archive response exposed run id or token: %s", body)
@@ -106,8 +108,9 @@ func TestHTTPUploadIdempotencyReturnsOriginalRunForSameBundle(t *testing.T) {
destinations: []string{destination}, destinations: []string{destination},
}}, 4, 1)) }}, 4, 1))
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: coordinator, coordinator: coordinator,
tokens: map[string]string{"reports-secret": "reports"}, tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
defer server.Close() defer server.Close()
@@ -133,8 +136,9 @@ func TestHTTPUploadIdempotencyReturnsConflictForDifferentBundle(t *testing.T) {
destinations: []string{destination}, destinations: []string{destination},
}}, 4, 1)) }}, 4, 1))
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: coordinator, coordinator: coordinator,
tokens: map[string]string{"reports-secret": "reports"}, tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
defer server.Close() defer server.Close()
@@ -146,7 +150,7 @@ func TestHTTPUploadIdempotencyReturnsConflictForDifferentBundle(t *testing.T) {
ID: "weather.daily.brentwood.2026-05-31", ID: "weather.daily.brentwood.2026-05-31",
})) }))
if status != http.StatusConflict { if status != http.StatusConflict {
t.Fatalf("POST /upload status = %d, want %d; body = %s", status, http.StatusConflict, body) t.Fatalf("POST upload status = %d, want %d; body = %s", status, http.StatusConflict, body)
} }
if strings.Contains(body, "reports-secret") { if strings.Contains(body, "reports-secret") {
t.Fatalf("conflict response exposed token: %s", body) t.Fatalf("conflict response exposed token: %s", body)
@@ -167,15 +171,16 @@ func TestHTTPUploadOversizedArchiveIsRejectedWithoutRunID(t *testing.T) {
cfg.Pipelines[0].Source.Upload.MaxUploadSize = &size cfg.Pipelines[0].Source.Upload.MaxUploadSize = &size
coordinator := NewUploadCoordinator(context.Background(), cfg) coordinator := NewUploadCoordinator(context.Background(), cfg)
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: coordinator, coordinator: coordinator,
tokens: map[string]string{"reports-secret": "reports"}, tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
defer server.Close() defer server.Close()
status, body := postHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{})) status, body := postHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{}))
if status != http.StatusRequestEntityTooLarge { if status != http.StatusRequestEntityTooLarge {
t.Fatalf("POST /upload status = %d, want %d; body = %s", status, http.StatusRequestEntityTooLarge, body) 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") { if strings.Contains(body, "run_id") || strings.Contains(body, "reports-secret") {
t.Fatalf("oversized response exposed run id or token: %s", body) t.Fatalf("oversized response exposed run id or token: %s", body)
@@ -210,8 +215,9 @@ func TestHTTPUploadSamePipelineRequestsSerialize(t *testing.T) {
}, },
}) })
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: coordinator, coordinator: coordinator,
tokens: map[string]string{"reports-secret": "reports"}, tokens: map[string]resolvedUploadToken{"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
defer server.Close() defer server.Close()
@@ -262,16 +268,17 @@ func TestHTTPUploadDifferentPipelinesRunConcurrently(t *testing.T) {
}) })
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: coordinator, coordinator: coordinator,
tokens: map[string]string{ tokens: map[string]resolvedUploadToken{
"one-secret": "reports-one", "one-secret": uploadHTTPTestToken("reports-one-reporter", "one-secret", "reports-one"),
"two-secret": "reports-two", "two-secret": uploadHTTPTestToken("reports-two-reporter", "two-secret", "reports-two"),
}, },
uploadPipelines: pipelineIDSet([]string{"reports-one", "reports-two"}),
} }
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
defer server.Close() defer server.Close()
firstRunID := submitHTTPUpload(t, server, "one-secret", ingest.ContentTypeTar, []byte("first")) firstRunID := submitHTTPUploadToPipeline(t, server, "reports-one", "one-secret", ingest.ContentTypeTar, []byte("first"))
secondRunID := submitHTTPUpload(t, server, "two-secret", ingest.ContentTypeTar, []byte("second")) secondRunID := submitHTTPUploadToPipeline(t, server, "reports-two", "two-secret", ingest.ContentTypeTar, []byte("second"))
waitForStartedPipelines(t, started, "reports-one", "reports-two") waitForStartedPipelines(t, started, "reports-one", "reports-two")
waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusRunning) waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusRunning)
waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusRunning) waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusRunning)
@@ -284,6 +291,199 @@ func TestHTTPUploadDifferentPipelinesRunConcurrently(t *testing.T) {
waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded) waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
} }
func TestHTTPUploadOneTokenCanUploadToMultiplePipelines(t *testing.T) {
firstDestination := t.TempDir()
secondDestination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports-one",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-one"),
destinations: []string{firstDestination},
},
{
id: "reports-two",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-two"),
destinations: []string{secondDestination},
},
}, 4, 1)
cfg.UploadTokens = []config.UploadToken{{
ID: "shared-reporter",
TokenEnv: "SHARED_UPLOAD_TOKEN",
AllowPipelines: []string{"reports-one", "reports-two"},
}}
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"SHARED_UPLOAD_TOKEN": "shared-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUploadToPipeline(t, server, "reports-one", "shared-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{
ID: "reports.one.2026-06-08",
}))
secondRunID := submitHTTPUploadToPipeline(t, server, "reports-two", "shared-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{
ID: "reports.two.2026-06-08",
}))
first := waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
second := waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
if first.PipelineID != "reports-one" || second.PipelineID != "reports-two" {
t.Fatalf("statuses pipeline = %q/%q, want reports-one/reports-two", first.PipelineID, second.PipelineID)
}
assertPublishedBundle(t, firstDestination)
assertPublishedBundle(t, secondDestination)
}
func TestHTTPUploadMultipleTokensCanUploadToOnePipeline(t *testing.T) {
destination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{{
id: "reports",
tokenEnv: "FIRST_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{destination},
}}, 4, 1)
cfg.UploadTokens = []config.UploadToken{
{ID: "first-reporter", TokenEnv: "FIRST_UPLOAD_TOKEN", AllowPipelines: []string{"reports"}},
{ID: "second-reporter", TokenEnv: "SECOND_UPLOAD_TOKEN", AllowPipelines: []string{"reports"}},
}
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"FIRST_UPLOAD_TOKEN": "first-secret",
"SECOND_UPLOAD_TOKEN": "second-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
firstRunID := submitHTTPUpload(t, server, "first-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{}))
secondRunID := submitHTTPUpload(t, server, "second-secret", ingest.ContentTypeTar, bundleArchive(t, false, testutil.BundleOptions{}))
first := waitForHTTPUploadStatus(t, server, firstRunID, UploadStatusSucceeded)
second := waitForHTTPUploadStatus(t, server, secondRunID, UploadStatusSucceeded)
if first.PipelineID != "reports" || second.PipelineID != "reports" {
t.Fatalf("statuses pipeline = %q/%q, want reports/reports", first.PipelineID, second.PipelineID)
}
assertPublishedBundle(t, destination)
}
func TestHTTPUploadRejectsDisallowedPipelineAndLegacyUploadWithoutQueueing(t *testing.T) {
coordinator := NewUploadCoordinator(context.Background(), httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports",
tokenEnv: "REPORTS_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports"),
destinations: []string{t.TempDir()},
},
{
id: "private",
tokenEnv: "PRIVATE_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "private"),
destinations: []string{t.TempDir()},
},
}, 4, 1))
handler := uploadHTTPHandler{
coordinator: coordinator,
tokens: map[string]resolvedUploadToken{
"reports-secret": uploadHTTPTestToken("reports-reporter", "reports-secret", "reports"),
},
uploadPipelines: pipelineIDSet([]string{"reports", "private"}),
}
server := httptest.NewServer(handler)
defer server.Close()
status, body := postHTTPUploadToPipeline(t, server, "private", "reports-secret", ingest.ContentTypeTar, []byte("archive"))
if status != http.StatusForbidden {
t.Fatalf("disallowed upload status = %d, want %d; body = %s", status, http.StatusForbidden, body)
}
status, body = postLegacyHTTPUpload(t, server, "reports-secret", ingest.ContentTypeTar, []byte("archive"))
if status != http.StatusNotFound {
t.Fatalf("legacy upload status = %d, want %d; body = %s", status, http.StatusNotFound, body)
}
if got := coordinator.QueueDepth(); got != 0 {
t.Fatalf("queue depth = %d, want 0", got)
}
}
func TestHTTPUploadPublishesThroughSelectedPipeline(t *testing.T) {
firstDestination := t.TempDir()
secondDestination := t.TempDir()
cfg := httpUploadIntegrationConfig(t, []httpUploadPipelineSpec{
{
id: "reports-one",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-one"),
destinations: []string{firstDestination},
},
{
id: "reports-two",
tokenEnv: "SHARED_UPLOAD_TOKEN",
stagingPath: filepath.Join(t.TempDir(), "reports-two"),
destinations: []string{secondDestination},
},
}, 4, 1)
cfg.UploadTokens = []config.UploadToken{{
ID: "shared-reporter",
TokenEnv: "SHARED_UPLOAD_TOKEN",
AllowPipelines: []string{"reports-one", "reports-two"},
}}
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
"SHARED_UPLOAD_TOKEN": "shared-secret",
}))
if err != nil {
t.Fatalf("newUploadHTTPHandler() error = %v", err)
}
server := httptest.NewServer(handler)
defer server.Close()
bundleRoot := t.TempDir()
testutil.WriteSourceBundle(t, bundleRoot, "", testutil.BundleOptions{
ID: "reports.selected.2026-06-08",
})
client, err := clientupload.NewClient(clientupload.ClientOptions{
Endpoint: server.URL,
Token: "shared-secret",
HTTPClient: server.Client(),
})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
result, err := client.UploadBundle(context.Background(), clientupload.UploadBundleOptions{
PipelineID: "reports-two",
Root: bundleRoot,
})
if err != nil {
t.Fatalf("UploadBundle() error = %v", err)
}
runID := UploadRunID(result.RunID)
record := waitForHTTPUploadStatus(t, server, runID, UploadStatusSucceeded)
if record.PipelineID != "reports-two" {
t.Fatalf("record pipeline = %q, want reports-two", record.PipelineID)
}
if record.Report == nil {
t.Fatal("completed status report = nil, want run report")
}
if got, want := len(record.Report.Pipelines), 1; got != want {
t.Fatalf("report pipeline count = %d, want %d", got, want)
}
if record.Report.Pipelines[0].ID != "reports-two" {
t.Fatalf("report pipeline = %q, want reports-two", record.Report.Pipelines[0].ID)
}
if got, want := len(record.Report.Actions), 1; got != want {
t.Fatalf("report action count = %d, want %d", got, want)
}
if record.Report.Actions[0].PipelineID != "reports-two" {
t.Fatalf("action pipeline = %q, want reports-two", record.Report.Actions[0].PipelineID)
}
assertDirectoryEmpty(t, firstDestination)
assertPublishedBundle(t, secondDestination)
}
type httpUploadPipelineSpec struct { type httpUploadPipelineSpec struct {
id string id string
tokenEnv string tokenEnv string
@@ -311,7 +511,6 @@ func httpUploadIntegrationConfig(t *testing.T, pipelines []httpUploadPipelineSpe
Source: config.Backend{ Source: config.Backend{
Backend: config.BackendHTTPUpload, Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{ Upload: config.HTTPUpload{
TokenEnv: spec.tokenEnv,
StagingPath: spec.stagingPath, StagingPath: spec.stagingPath,
MaxUploadSize: &size, MaxUploadSize: &size,
}, },
@@ -326,6 +525,11 @@ func httpUploadIntegrationConfig(t *testing.T, pipelines []httpUploadPipelineSpe
}) })
} }
cfg.Pipelines = append(cfg.Pipelines, pipeline) 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) config.ApplyDefaults(&cfg)
return cfg return cfg
@@ -333,7 +537,12 @@ func httpUploadIntegrationConfig(t *testing.T, pipelines []httpUploadPipelineSpe
func submitHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) UploadRunID { func submitHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) UploadRunID {
t.Helper() t.Helper()
status, responseBody := postHTTPUpload(t, server, token, contentType, body) 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) return decodeAcceptedHTTPUpload(t, status, responseBody)
} }
@@ -346,7 +555,7 @@ func submitHTTPUploadWithKey(t *testing.T, server *httptest.Server, token, conte
func decodeAcceptedHTTPUpload(t *testing.T, status int, responseBody string) UploadRunID { func decodeAcceptedHTTPUpload(t *testing.T, status int, responseBody string) UploadRunID {
t.Helper() t.Helper()
if status != http.StatusAccepted { if status != http.StatusAccepted {
t.Fatalf("POST /upload status = %d, want %d; body = %s", status, http.StatusAccepted, responseBody) t.Fatalf("POST upload status = %d, want %d; body = %s", status, http.StatusAccepted, responseBody)
} }
var accepted uploadAcceptedResponse var accepted uploadAcceptedResponse
if err := json.Unmarshal([]byte(responseBody), &accepted); err != nil { if err := json.Unmarshal([]byte(responseBody), &accepted); err != nil {
@@ -360,12 +569,22 @@ func decodeAcceptedHTTPUpload(t *testing.T, status int, responseBody string) Upl
func postHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) (int, string) { func postHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) (int, string) {
t.Helper() t.Helper()
return postHTTPUploadWithKey(t, server, token, contentType, "", body) 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) { func postHTTPUploadWithKey(t *testing.T, server *httptest.Server, token, contentType, key string, body []byte) (int, string) {
t.Helper() t.Helper()
request, err := http.NewRequest(http.MethodPost, server.URL+"/upload", bytes.NewReader(body)) 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 { if err != nil {
t.Fatalf("NewRequest() error = %v", err) t.Fatalf("NewRequest() error = %v", err)
} }
@@ -376,7 +595,27 @@ func postHTTPUploadWithKey(t *testing.T, server *httptest.Server, token, content
} }
response, err := server.Client().Do(request) response, err := server.Client().Do(request)
if err != nil { if err != nil {
t.Fatalf("POST /upload error = %v", err) t.Fatalf("POST upload error = %v", err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
return response.StatusCode, string(data)
}
func postLegacyHTTPUpload(t *testing.T, server *httptest.Server, token, contentType string, body []byte) (int, string) {
t.Helper()
request, err := http.NewRequest(http.MethodPost, server.URL+"/upload", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", contentType)
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("POST legacy upload error = %v", err)
} }
defer response.Body.Close() defer response.Body.Close()
data, err := io.ReadAll(response.Body) data, err := io.ReadAll(response.Body)

View File

@@ -53,10 +53,14 @@ func TestResolveUploadTokensFailsForMissingAndDuplicateTokens(t *testing.T) {
ID: "weekly", ID: "weekly",
Source: config.Backend{ Source: config.Backend{
Backend: config.BackendHTTPUpload, Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{TokenEnv: "OTHER_UPLOAD_TOKEN"},
}, },
Destinations: cfg.Pipelines[0].Destinations, 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) config.ApplyDefaults(&cfg)
secret := "super-secret-token" secret := "super-secret-token"
_, err = resolveUploadTokens(cfg, uploadHTTPTestEnvironment(map[string]string{ _, err = resolveUploadTokens(cfg, uploadHTTPTestEnvironment(map[string]string{
@@ -71,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) { func TestNewUploadHTTPHandlerAcceptsDefaultedConfig(t *testing.T) {
cfg := uploadHTTPTestConfig() cfg := uploadHTTPTestConfig()
cfg.Server.HTTP.Bind = "" cfg.Server.HTTP.Bind = ""
@@ -109,10 +146,11 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
return UploadRunRecord{ID: "reports.20260603T120000Z.abcdef12", Status: UploadStatusAccepted}, nil return UploadRunRecord{ID: "reports.20260603T120000Z.abcdef12", Status: UploadStatusAccepted}, nil
}, },
}, },
tokens: map[string]string{"valid-token": "reports"}, tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
recorder := httptest.NewRecorder() 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("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", "application/x-tar") request.Header.Set("Content-Type", "application/x-tar")
request.Header.Set("Idempotency-Key", "producer.retry:20260603") request.Header.Set("Idempotency-Key", "producer.retry:20260603")
@@ -125,6 +163,9 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
if submitted.PipelineID != "reports" { if submitted.PipelineID != "reports" {
t.Fatalf("submitted pipeline = %q, want reports", submitted.PipelineID) 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" { if submitted.IdempotencyKey != "producer.retry:20260603" {
t.Fatalf("submitted idempotency key = %q, want producer.retry:20260603", submitted.IdempotencyKey) t.Fatalf("submitted idempotency key = %q, want producer.retry:20260603", submitted.IdempotencyKey)
} }
@@ -142,13 +183,14 @@ func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) { func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) {
handler := uploadHTTPHandler{ handler := uploadHTTPHandler{
coordinator: fakeUploadCoordinator{}, coordinator: fakeUploadCoordinator{},
tokens: map[string]string{"valid-token": "reports"}, 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() 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("Authorization", authHeader)
request.Header.Set("Content-Type", "application/x-tar") request.Header.Set("Content-Type", "application/x-tar")
@@ -163,7 +205,73 @@ func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) {
} }
} }
func TestUploadHTTPHandlerRejectsUnsupportedContentTypeInvalidKeyAndPipelineID(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 { tests := []struct {
name string name string
url string url string
@@ -174,14 +282,14 @@ func TestUploadHTTPHandlerRejectsUnsupportedContentTypeInvalidKeyAndPipelineID(t
}{ }{
{ {
name: "unsupported content type", name: "unsupported content type",
url: "/upload", url: "/v1/pipelines/reports/upload",
contentType: "application/zip", contentType: "application/zip",
body: strings.NewReader("archive"), body: strings.NewReader("archive"),
wantStatus: http.StatusUnsupportedMediaType, wantStatus: http.StatusUnsupportedMediaType,
}, },
{ {
name: "invalid key syntax", name: "invalid key syntax",
url: "/upload", url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar", contentType: "application/x-tar",
keyValues: []string{"bad key"}, keyValues: []string{"bad key"},
body: strings.NewReader("archive"), body: strings.NewReader("archive"),
@@ -189,7 +297,7 @@ func TestUploadHTTPHandlerRejectsUnsupportedContentTypeInvalidKeyAndPipelineID(t
}, },
{ {
name: "empty key", name: "empty key",
url: "/upload", url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar", contentType: "application/x-tar",
keyValues: []string{""}, keyValues: []string{""},
body: strings.NewReader("archive"), body: strings.NewReader("archive"),
@@ -197,7 +305,7 @@ func TestUploadHTTPHandlerRejectsUnsupportedContentTypeInvalidKeyAndPipelineID(t
}, },
{ {
name: "too long key", name: "too long key",
url: "/upload", url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar", contentType: "application/x-tar",
keyValues: []string{strings.Repeat("a", 129)}, keyValues: []string{strings.Repeat("a", 129)},
body: strings.NewReader("archive"), body: strings.NewReader("archive"),
@@ -205,19 +313,12 @@ func TestUploadHTTPHandlerRejectsUnsupportedContentTypeInvalidKeyAndPipelineID(t
}, },
{ {
name: "multiple keys", name: "multiple keys",
url: "/upload", url: "/v1/pipelines/reports/upload",
contentType: "application/x-tar", contentType: "application/x-tar",
keyValues: []string{"one", "two"}, keyValues: []string{"one", "two"},
body: strings.NewReader("archive"), body: strings.NewReader("archive"),
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
}, },
{
name: "submitted pipeline id",
url: "/upload?pipeline_id=reports",
contentType: "application/x-tar",
body: strings.NewReader("archive"),
wantStatus: http.StatusBadRequest,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -228,7 +329,8 @@ func TestUploadHTTPHandlerRejectsUnsupportedContentTypeInvalidKeyAndPipelineID(t
return UploadRunRecord{}, nil return UploadRunRecord{}, nil
}, },
}, },
tokens: map[string]string{"valid-token": "reports"}, tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.url, tt.body) request := httptest.NewRequest(http.MethodPost, tt.url, tt.body)
@@ -270,10 +372,11 @@ func TestUploadHTTPHandlerMapsSubmitErrors(t *testing.T) {
return UploadRunRecord{}, tt.err return UploadRunRecord{}, tt.err
}, },
}, },
tokens: map[string]string{"valid-token": "reports"}, tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
recorder := httptest.NewRecorder() 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("Authorization", "Bearer valid-token")
request.Header.Set("Content-Type", "application/x-tar") request.Header.Set("Content-Type", "application/x-tar")
@@ -306,7 +409,8 @@ func TestUploadHTTPHandlerRunStatusAndHealth(t *testing.T) {
}, true }, true
}, },
}, },
tokens: map[string]string{"valid-token": "reports"}, tokens: map[string]resolvedUploadToken{"valid-token": uploadHTTPTestToken("reporter", "valid-token", "reports")},
uploadPipelines: pipelineIDSet([]string{"reports"}),
} }
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@@ -362,7 +466,6 @@ func uploadHTTPTestConfig() config.Config {
Source: config.Backend{ Source: config.Backend{
Backend: config.BackendHTTPUpload, Backend: config.BackendHTTPUpload,
Upload: config.HTTPUpload{ Upload: config.HTTPUpload{
TokenEnv: "UPLOAD_TOKEN",
StagingPath: "/tmp/distributor-test/reports", StagingPath: "/tmp/distributor-test/reports",
MaxUploadSize: &size, MaxUploadSize: &size,
}, },
@@ -374,6 +477,11 @@ func uploadHTTPTestConfig() config.Config {
Publish: &config.PublishPolicy{Source: true}, Publish: &config.PublishPolicy{Source: true},
}}, }},
}}, }},
UploadTokens: []config.UploadToken{{
ID: "reporter",
TokenEnv: "UPLOAD_TOKEN",
AllowPipelines: []string{"reports"},
}},
} }
config.ApplyDefaults(&cfg) config.ApplyDefaults(&cfg)
return cfg return cfg
@@ -384,3 +492,11 @@ func uploadHTTPTestEnvironment(values map[string]string) config.Environment {
return "", false return "", false
}) })
} }
func uploadHTTPTestToken(id, value string, pipelines ...string) resolvedUploadToken {
return resolvedUploadToken{
ID: id,
Value: value,
AllowedPipelines: pipelineIDSet(pipelines),
}
}

88
internal/cli/prune.go Normal file
View 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
View 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
}

View 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.
`)
}

View 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)
}
}

View File

@@ -29,6 +29,10 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return versionCommand(ctx, args[1:], stdout, stderr) return versionCommand(ctx, args[1:], stdout, stderr)
case "run": case "run":
return runCommand(ctx, args[1:], stdout, stderr) return runCommand(ctx, args[1:], stdout, stderr)
case "reconcile-state":
return reconcileStateCommand(ctx, args[1:], stdout, stderr)
case "prune":
return pruneCommand(ctx, args[1:], stdout, stderr)
case "serve": case "serve":
return serveCommand(ctx, args[1:], stdout, stderr) return serveCommand(ctx, args[1:], stdout, stderr)
case "validate": case "validate":
@@ -53,6 +57,9 @@ Usage:
Commands: Commands:
version Print version information version Print version information
run Run configured distribution pipelines 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 serve Run the HTTP upload server
validate Validate a source bundle or bundle tree validate Validate a source bundle or bundle tree
inspect Inspect bundles or distributor state inspect Inspect bundles or distributor state

View File

@@ -634,8 +634,8 @@ func TestExecuteRunDryRun(t *testing.T) {
} }
wantStdout := "Configured pipelines: 1\n" + wantStdout := "Configured pipelines: 1\n" +
"- pipeline=reports source=local bundles=1 destinations=archive\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" + " - 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 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true fixed_path=0\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 { if got := stdout.String(); got != wantStdout {
t.Fatalf("stdout = %q, want %q", 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) { func TestExecuteRunJSONDryRunReportsFixedPathMapping(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
@@ -871,8 +907,8 @@ func TestExecuteRunJSONPartialFailure(t *testing.T) {
firstDestination := t.TempDir() firstDestination := t.TempDir()
secondDestination := t.TempDir() secondDestination := t.TempDir()
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{}) testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(firstDestination, "report.md"), []byte("data"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err) t.Fatalf("write unmanaged planned file: %v", err)
} }
configPath := filepath.Join(t.TempDir(), "config.yml") configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(` if err := os.WriteFile(configPath, []byte(`
@@ -938,8 +974,8 @@ func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
if code != exitOK { if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
} }
if !strings.Contains(stdout.String(), "action=force_replace") { if !strings.Contains(stdout.String(), "action=force_replace workflow=additive") || !strings.Contains(stdout.String(), "force_replace=1") {
t.Fatalf("stdout = %q, want force_replace", stdout.String()) t.Fatalf("stdout = %q, want forced replacement", stdout.String())
} }
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); err != nil { if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); err != nil {
t.Fatalf("unmanaged file stat error = %v", err) t.Fatalf("unmanaged file stat error = %v", err)

View File

@@ -53,12 +53,15 @@ func TestBackendViewValidationKeepsHTTPUploadSourceOnly(t *testing.T) {
ID: "reports", ID: "reports",
Source: Backend{ Source: Backend{
Backend: BackendHTTPUpload, Backend: BackendHTTPUpload,
Upload: HTTPUpload{TokenEnv: "UPLOAD_TOKEN"},
}, },
Destinations: []Destination{{ Destinations: []Destination{{
ID: "archive", ID: "archive",
Backend: BackendHTTPUpload, Backend: BackendHTTPUpload,
}}, }},
}}, UploadTokens: []UploadToken{{
ID: "reporter",
TokenEnv: "UPLOAD_TOKEN",
AllowPipelines: []string{"reports"},
}}} }}}
ApplyDefaults(&cfg) ApplyDefaults(&cfg)

View File

@@ -1,9 +1,10 @@
package config package config
type Config struct { type Config struct {
Server Server `yaml:"server"` Server Server `yaml:"server"`
Secrets Secrets `yaml:"secrets"` Secrets Secrets `yaml:"secrets"`
Pipelines []Pipeline `yaml:"pipelines"` UploadTokens []UploadToken `yaml:"upload_tokens"`
Pipelines []Pipeline `yaml:"pipelines"`
} }
type Server struct { type Server struct {
@@ -23,6 +24,12 @@ type Secrets struct {
Directory string `yaml:"directory"` Directory string `yaml:"directory"`
} }
type UploadToken struct {
ID string `yaml:"id"`
TokenEnv string `yaml:"token_env"`
AllowPipelines []string `yaml:"allow_pipelines"`
}
type Pipeline struct { type Pipeline struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Source Backend `yaml:"source"` Source Backend `yaml:"source"`
@@ -31,24 +38,25 @@ type Pipeline struct {
} }
type Destination struct { type Destination struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Backend string `yaml:"backend"` Backend string `yaml:"backend"`
Host string `yaml:"host"` Host string `yaml:"host"`
User string `yaml:"user"` User string `yaml:"user"`
Port int `yaml:"port"` Port int `yaml:"port"`
Path string `yaml:"path"` Path string `yaml:"path"`
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
Bucket string `yaml:"bucket"` Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"` Prefix string `yaml:"prefix"`
Region string `yaml:"region"` Region string `yaml:"region"`
ForcePath *bool `yaml:"force_path_style"` ForcePath *bool `yaml:"force_path_style"`
Creds Credentials `yaml:"credentials"` Creds Credentials `yaml:"credentials"`
SSH SSH `yaml:",inline"` SSH SSH `yaml:",inline"`
Publish *PublishPolicy `yaml:"publish"` Publish *PublishPolicy `yaml:"publish"`
Transform Transform `yaml:"transform"` Transform Transform `yaml:"transform"`
PathMap PathMapping `yaml:"path_mapping"` PathMap PathMapping `yaml:"path_mapping"`
Links *Links `yaml:"links"` Links *Links `yaml:"links"`
Transfer TransferPolicy `yaml:"transfer"` Workflow string `yaml:"workflow"`
Retention RetentionPolicy `yaml:"retention"`
} }
type Backend struct { type Backend struct {
@@ -68,7 +76,6 @@ type Backend struct {
} }
type HTTPUpload struct { type HTTPUpload struct {
TokenEnv string `yaml:"token_env"`
StagingPath string `yaml:"staging_path"` StagingPath string `yaml:"staging_path"`
MaxUploadSize *ByteSize `yaml:"max_upload_size"` MaxUploadSize *ByteSize `yaml:"max_upload_size"`
} }
@@ -101,6 +108,7 @@ type MarkdownToHTML struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Mode string `yaml:"mode"` Mode string `yaml:"mode"`
Input string `yaml:"input"` Input string `yaml:"input"`
CssHref string `yaml:"css_href"`
} }
type PathMapping struct { type PathMapping struct {
@@ -112,9 +120,12 @@ type Links struct {
Primary string `yaml:"primary"` Primary string `yaml:"primary"`
} }
type TransferPolicy struct { type RetentionPolicy struct {
OnDestinationSame string `yaml:"on_destination_same"` Prune PrunePolicy `yaml:"prune"`
OnDestinationOlder string `yaml:"on_destination_older"` }
OnDestinationNewer string `yaml:"on_destination_newer"`
OnConflict string `yaml:"on_conflict"` type PrunePolicy struct {
Enabled bool `yaml:"enabled"`
OlderThan *Duration `yaml:"older_than"`
KeepLatest *int `yaml:"keep_latest"`
} }

View File

@@ -20,12 +20,6 @@ const (
ValidationActionFail = "fail" ValidationActionFail = "fail"
) )
const (
TransferActionSkip = "skip"
TransferActionReplace = "replace"
TransferActionFail = "fail"
)
const ( const (
TransformModeSidecar = transform.MarkdownModeSidecar TransformModeSidecar = transform.MarkdownModeSidecar
TransformModeIndex = transform.MarkdownModeIndex TransformModeIndex = transform.MarkdownModeIndex
@@ -42,6 +36,11 @@ const (
LinkPrimarySource = "source" LinkPrimarySource = "source"
) )
const (
WorkflowAdditive = "additive"
WorkflowReplacement = "replacement"
)
const DefaultS3Region = "us-east-1" const DefaultS3Region = "us-east-1"
const ( const (
@@ -79,17 +78,8 @@ func ApplyDefaults(cfg *Config) {
if destination.Links != nil && destination.Links.Primary == "" { if destination.Links != nil && destination.Links.Primary == "" {
destination.Links.Primary = LinkPrimaryAuto destination.Links.Primary = LinkPrimaryAuto
} }
if destination.Transfer.OnDestinationSame == "" { if destination.Workflow == "" {
destination.Transfer.OnDestinationSame = TransferActionSkip destination.Workflow = WorkflowAdditive
}
if destination.Transfer.OnDestinationOlder == "" {
destination.Transfer.OnDestinationOlder = TransferActionReplace
}
if destination.Transfer.OnDestinationNewer == "" {
destination.Transfer.OnDestinationNewer = TransferActionSkip
}
if destination.Transfer.OnConflict == "" {
destination.Transfer.OnConflict = TransferActionFail
} }
} }
} }

View File

@@ -30,8 +30,11 @@ pipelines:
if got, want := cfg.Pipelines[0].Validation.OnDigestMismatch, ValidationActionFail; got != want { if got, want := cfg.Pipelines[0].Validation.OnDigestMismatch, ValidationActionFail; got != want {
t.Fatalf("validation default = %q, want %q", got, want) t.Fatalf("validation default = %q, want %q", got, want)
} }
if got, want := destination.Transfer.OnDestinationOlder, TransferActionReplace; got != want { if got, want := destination.Workflow, WorkflowAdditive; got != want {
t.Fatalf("transfer default = %q, want %q", 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 != "" { if cfg.Secrets.Directory != "" {
t.Fatalf("secrets.directory = %q, want empty", 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) { func TestLoadFileAcceptsFixedPathMapping(t *testing.T) {
cfg := loadConfig(t, ` cfg := loadConfig(t, `
pipelines: pipelines:
@@ -256,13 +316,17 @@ pipelines:
- id: weather-daily - id: weather-daily
source: source:
backend: http_upload backend: http_upload
token_env: WEATHER_DAILY_UPLOAD_TOKEN
staging_path: /srv/distributor/staging/weather-daily staging_path: /srv/distributor/staging/weather-daily
max_upload_size: 32MB max_upload_size: 32MB
destinations: destinations:
- id: archive - id: archive
backend: local backend: local
path: /archive path: /archive
upload_tokens:
- id: weather-reporter
token_env: WEATHER_DAILY_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
`) `)
server := cfg.Server.HTTP server := cfg.Server.HTTP
@@ -289,9 +353,6 @@ pipelines:
if got, want := source.Backend, BackendHTTPUpload; got != want { if got, want := source.Backend, BackendHTTPUpload; got != want {
t.Fatalf("source.backend = %q, want %q", 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 { if got, want := source.Upload.StagingPath, "/srv/distributor/staging/weather-daily"; got != want {
t.Fatalf("source.staging_path = %q, want %q", got, want) t.Fatalf("source.staging_path = %q, want %q", got, want)
} }
@@ -309,11 +370,15 @@ pipelines:
- id: weather-daily - id: weather-daily
source: source:
backend: http_upload backend: http_upload
token_env: WEATHER_DAILY_UPLOAD_TOKEN
destinations: destinations:
- id: archive - id: archive
backend: local backend: local
path: /archive path: /archive
upload_tokens:
- id: weather-reporter
token_env: WEATHER_DAILY_UPLOAD_TOKEN
allow_pipelines:
- weather-daily
`) `)
source := cfg.Pipelines[0].Source 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) { func TestLoadFileValidBackendConfigs(t *testing.T) {
tests := map[string]string{ tests := map[string]string{
"local": ` "local": `
@@ -515,16 +694,23 @@ func TestLoadFileRejectsInvalidS3Config(t *testing.T) {
func TestLoadFileRejectsInvalidHTTPUploadConfig(t *testing.T) { func TestLoadFileRejectsInvalidHTTPUploadConfig(t *testing.T) {
tests := map[string]string{ tests := map[string]string{
"server size": `server: {http: {max_upload_size: 20XB}}`, "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}]}]`, "source size": `upload_tokens: [{id: reporter, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
"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}]}]`, 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}}`, "server duration": `server: {http: {retention: forever}}`,
"zero server duration": `server: {http: {retention: 0s}}`, "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}]}]`, "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}]}]`, "literal token": `upload_tokens: [{id: reporter, token: secret, token_env: UPLOAD_TOKEN, allow_pipelines: [reports]}]
"unknown server field": `server: {http: {surprise: true}}`, pipelines: [{id: reports, source: {backend: http_upload}, destinations: [{id: archive, backend: local, path: /archive}]}]`,
"unknown source field": `pipelines: [{id: reports, source: {backend: http_upload, token_env: UPLOAD_TOKEN, surprise: true}, 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 { for name, body := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -629,8 +815,63 @@ pipelines:
`, "backend ftp is unsupported") `, "backend ftp is unsupported")
} }
func TestLoadFileRejectsInvalidTransferAction(t *testing.T) { func TestLoadFileRejectsInvalidWorkflow(t *testing.T) {
assertLoadError(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: pipelines:
- id: reports - id: reports
source: source:
@@ -641,8 +882,14 @@ pipelines:
backend: local backend: local
path: /archive path: /archive
transfer: transfer:
on_destination_older: overwrite on_destination_older: replace
`, "on_destination_older must be replace or fail") `,
}
for name, body := range tests {
t.Run(name, func(t *testing.T) {
assertLoadError(t, body, "field "+name+" not found")
})
}
} }
func TestLoadFileRejectsInvalidValidationAction(t *testing.T) { func TestLoadFileRejectsInvalidValidationAction(t *testing.T) {
@@ -711,17 +958,14 @@ pipelines:
} }
func TestExampleConfigsLoad(t *testing.T) { func TestExampleConfigsLoad(t *testing.T) {
for _, path := range []string{ paths, err := filepath.Glob("../../examples/*.yml")
"../../examples/local-to-local.yml", if err != nil {
"../../examples/local-publish.yml", t.Fatalf("glob examples: %v", err)
"../../examples/local-html.yml", }
"../../examples/local-index.yml", if len(paths) == 0 {
"../../examples/fan-out.yml", t.Fatal("no example configs found")
"../../examples/archive-and-latest.yml", }
"../../examples/http-upload-local.yml", for _, path := range paths {
"../../examples/ssh-destination.yml",
"../../examples/s3-destination.yml",
} {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
if _, err := LoadFile(path); err != nil { if _, err := LoadFile(path); err != nil {
t.Fatalf("LoadFile(%q) error = %v", path, err) t.Fatalf("LoadFile(%q) error = %v", path, err)

View File

@@ -2,14 +2,20 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"regexp" "regexp"
"strings" "strings"
"unicode"
"gitea.maximumdirect.net/eric/distributor/internal/link" "gitea.maximumdirect.net/eric/distributor/internal/link"
) )
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
func IsSlugLikeID(value string) bool {
return idPattern.MatchString(value)
}
type ValidationErrors []string type ValidationErrors []string
func (e ValidationErrors) Error() string { func (e ValidationErrors) Error() string {
@@ -29,11 +35,12 @@ func Validate(cfg Config) error {
} }
pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines)) pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines))
uploadPipelineIDs := make(map[string]struct{})
for pipelineIndex, pipeline := range cfg.Pipelines { for pipelineIndex, pipeline := range cfg.Pipelines {
pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex) pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex)
if pipeline.ID == "" { if pipeline.ID == "" {
errs = append(errs, pipelineContext+".id is required") errs = append(errs, pipelineContext+".id is required")
} else if !idPattern.MatchString(pipeline.ID) { } else if !IsSlugLikeID(pipeline.ID) {
errs = append(errs, pipelineContext+".id must be a slug-like identifier") errs = append(errs, pipelineContext+".id must be a slug-like identifier")
} else if _, exists := pipelineIDs[pipeline.ID]; exists { } else if _, exists := pipelineIDs[pipeline.ID]; exists {
errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated") errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated")
@@ -42,6 +49,9 @@ func Validate(cfg Config) error {
} }
errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source) errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source)
if pipeline.Source.Backend == BackendHTTPUpload && pipeline.ID != "" {
uploadPipelineIDs[pipeline.ID] = struct{}{}
}
errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation) errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation)
if len(pipeline.Destinations) == 0 { if len(pipeline.Destinations) == 0 {
errs = append(errs, pipelineContext+".destinations is required") errs = append(errs, pipelineContext+".destinations is required")
@@ -52,7 +62,7 @@ func Validate(cfg Config) error {
destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex) destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex)
if destination.ID == "" { if destination.ID == "" {
errs = append(errs, destinationContext+".id is required") errs = append(errs, destinationContext+".id is required")
} else if !idPattern.MatchString(destination.ID) { } else if !IsSlugLikeID(destination.ID) {
errs = append(errs, destinationContext+".id must be a slug-like identifier") errs = append(errs, destinationContext+".id must be a slug-like identifier")
} else if _, exists := destinationIDs[destination.ID]; exists { } else if _, exists := destinationIDs[destination.ID]; exists {
errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID) errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID)
@@ -64,10 +74,13 @@ func Validate(cfg Config) error {
errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform) errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform)
errs = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap) errs = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap)
errs = validateLinks(errs, destinationContext+".links", destination.Links) 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 { if len(errs) > 0 {
return errs return errs
} }
@@ -112,9 +125,6 @@ func validateDestinationBackend(errs ValidationErrors, context string, destinati
} }
func validateHTTPUploadSource(errs ValidationErrors, context string, upload HTTPUpload) ValidationErrors { 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 == "" { if upload.StagingPath == "" {
errs = append(errs, context+".staging_path is required for http_upload backend") errs = append(errs, context+".staging_path is required for http_upload backend")
} }
@@ -124,6 +134,70 @@ func validateHTTPUploadSource(errs ValidationErrors, context string, upload HTTP
return errs return errs
} }
func validateUploadTokens(errs ValidationErrors, tokens []UploadToken, pipelineIDs, uploadPipelineIDs map[string]struct{}) ValidationErrors {
if len(uploadPipelineIDs) == 0 {
if len(tokens) > 0 {
errs = append(errs, "upload_tokens must reference configured http_upload pipelines")
}
return errs
}
if len(tokens) == 0 {
return append(errs, "upload_tokens is required when any pipeline source backend is http_upload")
}
tokenIDs := make(map[string]struct{}, len(tokens))
allowedUploadPipelineIDs := make(map[string]struct{}, len(uploadPipelineIDs))
for tokenIndex, token := range tokens {
context := fmt.Sprintf("upload_tokens[%d]", tokenIndex)
if token.ID == "" {
errs = append(errs, context+".id is required")
} else if !IsSlugLikeID(token.ID) {
errs = append(errs, context+".id must be a slug-like identifier")
} else if _, exists := tokenIDs[token.ID]; exists {
errs = append(errs, "upload token id "+token.ID+" is duplicated")
} else {
tokenIDs[token.ID] = struct{}{}
}
if token.TokenEnv == "" {
errs = append(errs, context+".token_env is required")
}
if len(token.AllowPipelines) == 0 {
errs = append(errs, context+".allow_pipelines is required")
}
seenAllowed := make(map[string]struct{}, len(token.AllowPipelines))
for allowIndex, pipelineID := range token.AllowPipelines {
allowContext := fmt.Sprintf("%s.allow_pipelines[%d]", context, allowIndex)
if pipelineID == "" {
errs = append(errs, allowContext+" is required")
continue
}
if _, exists := seenAllowed[pipelineID]; exists {
errs = append(errs, context+".allow_pipelines contains duplicate pipeline id "+pipelineID)
continue
}
seenAllowed[pipelineID] = struct{}{}
if _, exists := pipelineIDs[pipelineID]; !exists {
errs = append(errs, allowContext+" references unknown pipeline "+pipelineID)
continue
}
if _, exists := uploadPipelineIDs[pipelineID]; !exists {
errs = append(errs, allowContext+" references non-http_upload pipeline "+pipelineID)
continue
}
allowedUploadPipelineIDs[pipelineID] = struct{}{}
}
}
for pipelineID := range uploadPipelineIDs {
if _, exists := allowedUploadPipelineIDs[pipelineID]; !exists {
errs = append(errs, "http_upload pipeline "+pipelineID+" is not allowed by any upload token")
}
}
return errs
}
func validateBackend(errs ValidationErrors, context string, backend backendView) ValidationErrors { func validateBackend(errs ValidationErrors, context string, backend backendView) ValidationErrors {
switch backend.Backend { switch backend.Backend {
case "": case "":
@@ -207,9 +281,15 @@ func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform)
if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled { if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled {
return fmt.Errorf("transform.markdown_to_html.input requires transform.markdown_to_html.enabled to be true") 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 { if transform.MarkdownToHTML.Input != "" && mode != TransformModeIndex {
return fmt.Errorf("transform.markdown_to_html.input is only valid when mode is %s", 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 { if transform.MarkdownToHTML.Enabled && !publish.HTML {
return fmt.Errorf("transform.markdown_to_html.enabled requires publish.html to be true") 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 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 { func validatePathMapping(errs ValidationErrors, context string, mapping PathMapping) ValidationErrors {
if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed { if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed {
errs = append(errs, context+".mode must be "+PathMappingPreserveRelative+" or "+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 return errs
} }
func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors { func validateWorkflow(errs ValidationErrors, context, workflow string) ValidationErrors {
if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail { if workflow != WorkflowAdditive && workflow != WorkflowReplacement {
errs = append(errs, context+".on_destination_same must be skip or fail") errs = append(errs, context+" must be "+WorkflowAdditive+" or "+WorkflowReplacement)
} }
if policy.OnDestinationOlder != TransferActionReplace && policy.OnDestinationOlder != TransferActionFail { return errs
errs = append(errs, context+".on_destination_older must be replace or fail") }
}
if policy.OnDestinationNewer != TransferActionSkip && policy.OnDestinationNewer != TransferActionFail && policy.OnDestinationNewer != TransferActionReplace { func validateRetentionPolicy(errs ValidationErrors, context string, policy RetentionPolicy) ValidationErrors {
errs = append(errs, context+".on_destination_newer must be skip, replace, or fail") prune := policy.Prune
} if !prune.Enabled {
if policy.OnConflict != TransferActionFail && policy.OnConflict != TransferActionReplace { return errs
errs = append(errs, context+".on_conflict must be fail or replace") }
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 return errs
} }

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"strings" "strings"
"testing" "testing"
"time"
) )
func TestValidatePublishTransformPolicy(t *testing.T) { 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) { func TestValidatePathMapping(t *testing.T) {
tests := []struct { tests := []struct {
name string 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) { func TestValidateLinks(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -215,6 +293,24 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
Input: "report.md", 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", name: "source and html sidecar allowed",
publish: PublishPolicy{Source: true, HTML: true}, publish: PublishPolicy{Source: true, HTML: true},
@@ -312,6 +408,16 @@ func publishTransformPolicyCases() []publishTransformPolicyCase {
}}, }},
wantErr: true, 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", name: "disabled markdown wrong mode rejected",
publish: PublishPolicy{Source: true}, 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)
}
})
}
}

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "sort"
"gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
@@ -12,24 +12,16 @@ import (
func Execute(ctx context.Context, req Request, plan Plan) error { func Execute(ctx context.Context, req Request, plan Plan) error {
switch plan.Action { switch plan.Action {
case ActionSkipSame, ActionSkipDestinationNewer: case ActionSkipSame:
return nil return nil
case ActionPublishNew, ActionReplaceOlder, ActionForceReplace: case ActionPublishNew, ActionUpsertAdditive, ActionReplaceCatalog, ActionForceReplace:
return executeCatalog(ctx, req, plan)
default: default:
return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason) return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason)
} }
}
if plan.Action == ActionReplaceOlder { func executeCatalog(ctx context.Context, req Request, plan Plan) error {
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
}
}
if plan.Action == ActionForceReplace { if plan.Action == ActionForceReplace {
if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { if err := req.DestinationBackend.DeletePrefix(ctx, req.DestinationBundlePath, storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
return err 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 { if err := ensureDestinationEmpty(ctx, req.DestinationBackend, req.DestinationBundlePath); err != nil {
return err 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)) writtenOutputs := make([]Output, 0, len(plan.Outputs))
newOutputs := make([]Output, 0, len(plan.Outputs))
cleanup := func() { 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 { for _, output := range plan.Outputs {
destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath) destinationPath, err := storage.Join(req.DestinationBundlePath, output.DestinationPath)
@@ -49,6 +59,11 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
cleanup() cleanup()
return err return err
} }
created, err := catalogWriteCreatesOutput(ctx, req.DestinationBackend, destinationPath)
if err != nil {
cleanup()
return err
}
data := output.Data data := output.Data
if output.Kind == state.OutputKindSource { if output.Kind == state.OutputKindSource {
sourcePath, err := storage.Join(req.SourceBundle.RootRelativePath, output.SourcePath) 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 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() cleanup()
return err return err
} }
writtenOutputs = append(writtenOutputs, output) writtenOutputs = append(writtenOutputs, output)
if created {
newOutputs = append(newOutputs, output)
}
} }
destinationState := state.DistributorState{ catalogState := catalogStateForPlan(req, plan)
SchemaVersion: state.SchemaVersion, if err := state.ValidateCatalog(catalogState); err != nil {
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 {
cleanup() cleanup()
return err return err
} }
data, err := json.MarshalIndent(destinationState, "", " ") data, err := json.MarshalIndent(catalogState, "", " ")
if err != nil { if err != nil {
cleanup() cleanup()
return err return err
@@ -96,9 +103,73 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
cleanup() cleanup()
return err 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() cleanup()
return err return err
} }
return nil 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
}

View File

@@ -2,63 +2,226 @@ package publish
import ( import (
"context" "context"
"fmt"
"io"
"testing" "testing"
"gitea.maximumdirect.net/eric/distributor/internal/config" "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"
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake" "gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
"gitea.maximumdirect.net/eric/distributor/internal/testutil" "gitea.maximumdirect.net/eric/distributor/internal/testutil"
) )
func TestExecuteCleansUpAfterWriteFailure(t *testing.T) { func TestExecuteAdditiveWritesOutputsAndCatalog(t *testing.T) {
sourceBackend := fake.New() _, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
destinationBackend := &failingBackend{Backend: fake.New(), failPath: "summary.txt"} existing := baseCatalog(req)
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{}) existing.Outputs = []state.CatalogOutputFile{
req := Request{ catalogOutput(req, "reports", "archive", "report.md", state.OutputKindSource, planCreatedAt),
PipelineID: "reports", catalogOutput(req, "reports", "web", "old.txt", state.OutputKindSource, planCreatedAt),
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",
} }
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) plan, err := Build(context.Background(), req)
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) 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) err = Execute(context.Background(), req, plan)
if err == nil { 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 { if err != nil {
t.Fatalf("HasAny() error = %v", err) t.Fatalf("state path: %v", err)
} }
if found { data, err := backend.ReadFile(context.Background(), statePath)
t.Fatal("destination has content after failed execution") if err != nil {
t.Fatalf("read catalog state: %v", err)
} }
} catalog, err := state.ParseCatalog(data)
if err != nil {
type failingBackend struct { t.Fatalf("parse catalog state: %v", err)
*fake.Backend
failPath string
}
func (b *failingBackend) WriteFile(ctx context.Context, path string, data []byte, opts storage.WriteOptions) (storage.Entry, error) {
if path == b.failPath {
return storage.Entry{}, fmt.Errorf("injected write failure")
} }
return b.Backend.WriteFile(ctx, path, data, opts) return catalog
}
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)
} }

View File

@@ -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
}

View File

@@ -27,8 +27,9 @@ func PlanOutputs(ctx context.Context, req Request) ([]Output, error) {
SourceBundle: req.SourceBundle, SourceBundle: req.SourceBundle,
SourceBackend: req.SourceBackend, SourceBackend: req.SourceBackend,
Markdown: transform.MarkdownOptions{ Markdown: transform.MarkdownOptions{
Mode: req.Transform.MarkdownToHTML.Mode, Mode: req.Transform.MarkdownToHTML.Mode,
Input: req.Transform.MarkdownToHTML.Input, Input: req.Transform.MarkdownToHTML.Input,
CssHref: req.Transform.MarkdownToHTML.CssHref,
}, },
}) })
if err != nil { if err != nil {
@@ -97,30 +98,10 @@ func rejectOutputCollisions(outputs []Output) error {
return nil 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 { func (o Output) ManagedPath() string {
return o.DestinationPath 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 { func ManagedOutputPaths(outputs []Output) []string {
paths := make([]string, 0, len(outputs)) paths := make([]string, 0, len(outputs))
for _, output := range outputs { for _, output := range outputs {
@@ -128,11 +109,3 @@ func ManagedOutputPaths(outputs []Output) []string {
} }
return paths 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
}

View File

@@ -12,46 +12,12 @@ import (
"gitea.maximumdirect.net/eric/distributor/internal/transform" "gitea.maximumdirect.net/eric/distributor/internal/transform"
) )
func TestOutputStateProjection(t *testing.T) { func TestManagedOutputPaths(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) {
outputs := []Output{ outputs := []Output{
{SourcePath: "report.md", DestinationPath: "report.md", Kind: state.OutputKindSource, SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Size: 1}, {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}, {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) paths := ManagedOutputPaths(outputs)
if len(paths) != 2 || paths[0] != "report.md" || paths[1] != "report.html" { if len(paths) != 2 || paths[0] != "report.md" || paths[1] != "report.html" {
t.Fatalf("managed paths = %#v", paths) t.Fatalf("managed paths = %#v", paths)
@@ -167,6 +133,7 @@ func TestPlanOutputsPassesMarkdownOptions(t *testing.T) {
Enabled: true, Enabled: true,
Mode: config.TransformModeIndex, Mode: config.TransformModeIndex,
Input: "report.md", Input: "report.md",
CssHref: "/assets/report.css",
}}, }},
Transformers: testResolver{transform.MarkdownToHTML: transformer}, Transformers: testResolver{transform.MarkdownToHTML: transformer},
}) })
@@ -174,8 +141,8 @@ func TestPlanOutputsPassesMarkdownOptions(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("PlanOutputs() error = %v", err) t.Fatalf("PlanOutputs() error = %v", err)
} }
if transformer.request.Markdown.Mode != config.TransformModeIndex || transformer.request.Markdown.Input != "report.md" { 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", transformer.request.Markdown) 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: "", DestinationBundlePath: "",
SourceBundle: sourceBundle, SourceBundle: sourceBundle,
Publish: config.PublishPolicy{HTML: true}, Publish: config.PublishPolicy{HTML: true},
Transfer: config.TransferPolicy{
OnDestinationSame: config.TransferActionSkip,
OnDestinationOlder: config.TransferActionReplace,
OnDestinationNewer: config.TransferActionSkip,
OnConflict: config.TransferActionFail,
},
}) })
if err == nil { if err == nil {
t.Fatal("Build() error = nil, want missing transform error") t.Fatal("Build() error = nil, want missing transform error")

View File

@@ -3,6 +3,7 @@ package publish
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle" "gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/config" "gitea.maximumdirect.net/eric/distributor/internal/config"
@@ -14,13 +15,13 @@ import (
type Action string type Action string
const ( const (
ActionPublishNew Action = "publish_new" ActionPublishNew Action = "publish_new"
ActionReplaceOlder Action = "replace_older" ActionSkipSame Action = "skip_same"
ActionSkipSame Action = "skip_same" ActionFailConflict Action = "fail_conflict"
ActionSkipDestinationNewer Action = "skip_destination_newer" ActionFailUnmanaged Action = "fail_unmanaged"
ActionFailConflict Action = "fail_conflict" ActionForceReplace Action = "force_replace"
ActionFailUnmanaged Action = "fail_unmanaged" ActionUpsertAdditive Action = "upsert_additive"
ActionForceReplace Action = "force_replace" ActionReplaceCatalog Action = "replace_catalog"
) )
type Request struct { type Request struct {
@@ -34,31 +35,17 @@ type Request struct {
Publish config.PublishPolicy Publish config.PublishPolicy
Transform config.Transform Transform config.Transform
Links *config.Links Links *config.Links
Workflow string
Transformers TransformerResolver Transformers TransformerResolver
Transfer config.TransferPolicy
DistributorVersion string DistributorVersion string
Force bool Force bool
Now time.Time
} }
type TransformerResolver interface { type TransformerResolver interface {
Get(name string) (transform.Transformer, bool) 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 { type Output struct {
SourcePath string SourcePath string
DestinationPath string DestinationPath string
@@ -70,6 +57,37 @@ type Output struct {
Size int64 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) { func Build(ctx context.Context, req Request) (Plan, error) {
if err := validateRequest(req); err != nil { if err := validateRequest(req); err != nil {
return Plan{}, err return Plan{}, err
@@ -86,8 +104,9 @@ func Build(ctx context.Context, req Request) (Plan, error) {
if err != nil { if err != nil {
return Plan{}, err return Plan{}, err
} }
comparison := compareDestination(req, status) workflow := normalizeWorkflow(req.Workflow)
action, reason := actionForComparison(comparison, req.Transfer, req.Force) scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
now := requestTime(req)
plan := Plan{ plan := Plan{
PipelineID: req.PipelineID, PipelineID: req.PipelineID,
DestinationID: req.DestinationID, DestinationID: req.DestinationID,
@@ -95,15 +114,52 @@ func Build(ctx context.Context, req Request) (Plan, error) {
BundlePath: req.SourceBundle.RootRelativePath, BundlePath: req.SourceBundle.RootRelativePath,
DestinationBundlePath: req.DestinationBundlePath, DestinationBundlePath: req.DestinationBundlePath,
PathMapping: req.PathMapping, PathMapping: req.PathMapping,
Action: action,
Reason: reason,
Force: action == ActionForceReplace,
PrimaryURL: primaryURL, PrimaryURL: primaryURL,
Workflow: workflow,
OwnerScope: scope,
Outputs: outputs, Outputs: outputs,
ExistingState: status.State, ExistingCatalog: status.Catalog,
SupersededLegacy: status.SupersededLegacy,
} }
if action == ActionFailConflict || action == ActionFailUnmanaged { if status.StateErr != nil {
return plan, fmt.Errorf("%s: %s", action, reason) 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 return plan, nil
} }
@@ -124,65 +180,202 @@ func validateRequest(req Request) error {
if err := config.ValidatePublishTransformPolicy(req.Publish, req.Transform); err != nil { if err := config.ValidatePublishTransformPolicy(req.Publish, req.Transform); err != nil {
return fmt.Errorf("publish/transform policy: %w", err) 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 return nil
} }
func compareDestination(req Request, status state.DestinationStatus) state.Comparison { func normalizeWorkflow(workflow string) string {
comparison := state.Compare(req.SourceBundle.Manifest, req.PipelineID, req.DestinationID, status) if workflow == "" {
if req.PathMapping != config.PathMappingFixed || comparison.Outcome != state.OutcomeDifferentSourceConflict || status.State == nil { return config.WorkflowAdditive
return comparison
} }
destinationManifest := status.State.Source.Manifest return workflow
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
} }
func actionForComparison(comparison state.Comparison, transfer config.TransferPolicy, force bool) (Action, string) { func requestTime(req Request) time.Time {
switch comparison.Outcome { if req.Now.IsZero() {
case state.OutcomeDestinationAbsent: return time.Now().UTC()
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"
} }
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
} }

View File

@@ -0,0 +1,624 @@
package publish
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
"gitea.maximumdirect.net/eric/distributor/internal/transform"
)
var (
planCreatedAt = time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
planUpdatedAt = time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC)
)
func TestBuildAdditivePublishesCatalogOutputs(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 plan.Action != ActionPublishNew || plan.Workflow != config.WorkflowAdditive {
t.Fatalf("plan action=%s workflow=%s, want publish_new additive", plan.Action, plan.Workflow)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
for _, output := range plan.CatalogOutputsToWrite {
if output.PipelineID != "reports" || output.DestinationID != "archive" {
t.Fatalf("catalog output owner = %s/%s", output.PipelineID, output.DestinationID)
}
if output.Source.ID != req.SourceBundle.Manifest.ID || output.Source.Digest != req.SourceBundle.Manifest.Digest || !output.Source.Created.Equal(req.SourceBundle.Manifest.Created) {
t.Fatalf("catalog output source = %#v", output.Source)
}
if output.Kind == state.OutputKindSource && output.SourcePath != "" {
t.Fatalf("source catalog output source_path = %q, want empty", output.SourcePath)
}
if !output.CreatedAt.Equal(planUpdatedAt) || !output.UpdatedAt.Equal(planUpdatedAt) {
t.Fatalf("catalog output times = %s/%s", output.CreatedAt, output.UpdatedAt)
}
}
if _, err := destinationBackend.Stat(context.Background(), storage.StateFileName); !storage.IsNotFound(err) {
t.Fatalf("destination state stat error = %v, want missing", err)
}
}
func TestBuildAdditiveOverwritesManagedAndRetainsOthers(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")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionUpsertAdditive {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionUpsertAdditive)
}
if len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("delete outputs = %#v, want none", plan.CatalogOutputsToDelete)
}
if len(plan.CatalogOutputsToRetain) != 1 || plan.CatalogOutputsToRetain[0].Path != "old.txt" {
t.Fatalf("retained outputs = %#v, want old.txt", plan.CatalogOutputsToRetain)
}
written, ok := state.FindCatalogOutputByPath(plan.CatalogOutputsToWrite, "report.md")
if !ok {
t.Fatalf("written outputs = %#v, want report.md", plan.CatalogOutputsToWrite)
}
if !written.CreatedAt.Equal(planCreatedAt) || !written.UpdatedAt.Equal(planUpdatedAt) {
t.Fatalf("report.md times = %s/%s, want created preserved and updated now", written.CreatedAt, written.UpdatedAt)
}
}
func TestBuildReplacementDeletesCurrentOwnerAndRetainsOtherOwners(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)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceCatalog {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionReplaceCatalog)
}
if len(plan.CatalogOutputsToDelete) != 1 || plan.CatalogOutputsToDelete[0].Path != "stale.txt" {
t.Fatalf("delete outputs = %#v, want stale.txt", plan.CatalogOutputsToDelete)
}
if len(plan.CatalogOutputsToRetain) != 1 || plan.CatalogOutputsToRetain[0].Path != "shared.txt" {
t.Fatalf("retained outputs = %#v, want shared.txt", plan.CatalogOutputsToRetain)
}
}
func TestBuildAdditiveSkipsMatchingCatalogOutputs(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
existing := matchingCatalogForRequest(t, req)
existing.Outputs = append(existing.Outputs, catalogOutput(req, "reports", "web", "old.txt", state.OutputKindSource, planCreatedAt))
writeCatalogState(t, destinationBackend, "", existing)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionSkipSame {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionSkipSame)
}
if len(plan.CatalogOutputsToWrite) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d delete=%d, want no writes or deletes", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToDelete))
}
if len(plan.CatalogOutputsToRetain) != 1 || plan.CatalogOutputsToRetain[0].Path != "old.txt" {
t.Fatalf("retained outputs = %#v, want old.txt", plan.CatalogOutputsToRetain)
}
}
func TestBuildReplacementSkipsMatchingCatalogOutputs(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowReplacement)
existing := matchingCatalogForRequest(t, req)
existing.Outputs = append(existing.Outputs, catalogOutput(req, "reports", "web", "shared.txt", state.OutputKindSource, planCreatedAt))
writeCatalogState(t, destinationBackend, "", existing)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionSkipSame {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionSkipSame)
}
if len(plan.CatalogOutputsToWrite) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d delete=%d, want no writes or deletes", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToDelete))
}
if len(plan.CatalogOutputsToRetain) != 1 || plan.CatalogOutputsToRetain[0].Path != "shared.txt" {
t.Fatalf("retained outputs = %#v, want shared.txt", plan.CatalogOutputsToRetain)
}
}
func TestBuildReplacementDoesNotSkipWhenCurrentOwnerOutputWouldBeDeleted(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowReplacement)
existing := matchingCatalogForRequest(t, req)
existing.Outputs = append(existing.Outputs, catalogOutput(req, "reports", "archive", "stale.txt", state.OutputKindSource, planCreatedAt))
writeCatalogState(t, destinationBackend, "", existing)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceCatalog {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionReplaceCatalog)
}
if len(plan.CatalogOutputsToDelete) != 1 || plan.CatalogOutputsToDelete[0].Path != "stale.txt" {
t.Fatalf("delete outputs = %#v, want stale.txt", plan.CatalogOutputsToDelete)
}
}
func TestBuildDoesNotSkipWhenCatalogMetadataDiffers(t *testing.T) {
tests := []struct {
name string
configure func(*Request)
mutate func(*state.CatalogState)
}{
{
name: "pipeline id",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].PipelineID = "other"
},
},
{
name: "destination id",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].DestinationID = "web"
},
},
{
name: "source id",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].Source.ID = "reports.other"
},
},
{
name: "source digest",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].Source.Digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
},
{
name: "source created",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].Source.Created = catalog.Outputs[0].Source.Created.Add(time.Second)
},
},
{
name: "output kind",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].Kind = state.OutputKindGenerated
catalog.Outputs[0].SourcePath = "report.md"
catalog.Outputs[0].Transform = transform.MarkdownToHTML
},
},
{
name: "output digest",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].SHA256 = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
},
{
name: "output size",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].Size++
},
},
{
name: "url",
configure: func(req *Request) {
req.Links = &config.Links{BaseURL: "https://reports.example.com/archive", Primary: config.LinkPrimaryAuto}
},
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].URL = "https://reports.example.com/archive/old-report.md"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
if tt.configure != nil {
tt.configure(&req)
}
existing := matchingCatalogForRequest(t, req)
tt.mutate(&existing)
writeCatalogState(t, destinationBackend, "", existing)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action == ActionSkipSame {
t.Fatalf("plan action = %s, want write action after metadata change", plan.Action)
}
})
}
}
func TestBuildDoesNotSkipWhenGeneratedCatalogMetadataDiffers(t *testing.T) {
tests := []struct {
name string
mutate func(*state.CatalogState)
}{
{
name: "source path",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].SourcePath = "summary.md"
},
},
{
name: "transform",
mutate: func(catalog *state.CatalogState) {
catalog.Outputs[0].Transform = "markdown_to_html_index"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogGeneratedPlanRequest(t, config.WorkflowAdditive)
existing := matchingCatalogForRequest(t, req)
tt.mutate(&existing)
writeCatalogState(t, destinationBackend, "", existing)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action == ActionSkipSame {
t.Fatalf("plan action = %s, want write action after generated metadata change", plan.Action)
}
})
}
}
func TestBuildTransfersManagedPathOwnership(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowReplacement)
existing := baseCatalog(req)
existing.Outputs = []state.CatalogOutputFile{
catalogOutput(req, "reports", "web", "report.md", state.OutputKindSource, planCreatedAt),
}
writeCatalogState(t, destinationBackend, "", existing)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
written, ok := state.FindCatalogOutputByPath(plan.CatalogOutputsToWrite, "report.md")
if !ok {
t.Fatalf("written outputs = %#v, want report.md", plan.CatalogOutputsToWrite)
}
if written.PipelineID != "reports" || written.DestinationID != "archive" {
t.Fatalf("written owner = %s/%s, want reports/archive", written.PipelineID, written.DestinationID)
}
if !written.CreatedAt.Equal(planCreatedAt) || !written.UpdatedAt.Equal(planUpdatedAt) {
t.Fatalf("written times = %s/%s", written.CreatedAt, written.UpdatedAt)
}
if len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("retain=%#v delete=%#v, want no old record for overwritten path", plan.CatalogOutputsToRetain, plan.CatalogOutputsToDelete)
}
}
func TestBuildRejectsUnmanagedPlannedPathCollision(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
writeCatalogState(t, destinationBackend, "", baseCatalog(req))
testutil.WriteFakeFile(t, destinationBackend, "report.md", "unmanaged")
plan, err := Build(context.Background(), req)
if err == nil {
t.Fatal("Build() error = nil, want unmanaged collision")
}
if plan.Action != ActionFailUnmanaged || !strings.Contains(err.Error(), "not managed by catalog state") {
t.Fatalf("plan action=%s error=%v, want unmanaged catalog collision", plan.Action, err)
}
}
func TestBuildForceReplacesUnmanagedPlannedPathCollision(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
existing := baseCatalog(req)
writeCatalogState(t, destinationBackend, "", existing)
testutil.WriteFakeFile(t, destinationBackend, "report.md", "unmanaged")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
}
func TestBuildRejectsNoStateNonEmptyDestinationWithoutForce(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "unmanaged")
plan, err := Build(context.Background(), req)
if err == nil {
t.Fatal("Build() error = nil, want unmanaged destination")
}
if plan.Action != ActionFailUnmanaged || !strings.Contains(err.Error(), "destination has content but no distributor state") {
t.Fatalf("plan action=%s error=%v, want unmanaged destination", plan.Action, err)
}
}
func TestBuildForceReplacesNoStateNonEmptyDestination(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, "unplanned.txt", "unmanaged")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
}
func TestBuildPlansSupersededLegacyAdditive(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
legacyState := legacyStateDocument(2)
writeJSONState(t, destinationBackend, "", legacyState)
testutil.WriteFakeFile(t, destinationBackend, "report.md", "legacy")
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.SupersededLegacy == nil || plan.SupersededLegacy.SchemaVersion != 2 {
t.Fatalf("superseded legacy = %#v", plan.SupersededLegacy)
}
if plan.Action != ActionUpsertAdditive || plan.ClearDestinationRoot {
t.Fatalf("plan action=%s clear=%t, want additive overwrite without clear", plan.Action, plan.ClearDestinationRoot)
}
}
func TestBuildPlansSupersededLegacyReplacementClear(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowReplacement)
legacyState := legacyStateDocument(2)
writeJSONState(t, destinationBackend, "", legacyState)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceCatalog || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s clear=%t, want replacement clear", plan.Action, plan.ClearDestinationRoot)
}
}
func TestBuildRejectsInvalidOrFutureState(t *testing.T) {
tests := []struct {
name string
data string
}{
{name: "invalid json", data: `{"schema_version":`},
{name: "future schema", data: `{"schema_version":99}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
testutil.WriteFakeFile(t, destinationBackend, storage.StateFileName, tt.data)
plan, err := Build(context.Background(), req)
if err == nil {
t.Fatal("Build() error = nil, want conflict")
}
if plan.Action != ActionFailConflict {
t.Fatalf("plan action = %s, want %s", plan.Action, ActionFailConflict)
}
})
}
}
func TestBuildForceReplacesInvalidOrFutureState(t *testing.T) {
tests := []struct {
name string
data string
}{
{name: "invalid json", data: `{"schema_version":`},
{name: "future schema", data: `{"schema_version":99}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, config.WorkflowAdditive)
req.Force = true
testutil.WriteFakeFile(t, destinationBackend, storage.StateFileName, tt.data)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionForceReplace || !plan.Force || !plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot)
}
if len(plan.CatalogOutputsToWrite) != 2 || len(plan.CatalogOutputsToRetain) != 0 || len(plan.CatalogOutputsToDelete) != 0 {
t.Fatalf("catalog write=%d retain=%d delete=%d", len(plan.CatalogOutputsToWrite), len(plan.CatalogOutputsToRetain), len(plan.CatalogOutputsToDelete))
}
})
}
}
func TestBuildForceDoesNotChangeValidCatalogActions(t *testing.T) {
tests := []struct {
name string
workflow string
want Action
}{
{name: "additive", workflow: config.WorkflowAdditive, want: ActionUpsertAdditive},
{name: "replacement", workflow: config.WorkflowReplacement, want: ActionReplaceCatalog},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, destinationBackend, req := catalogPlanRequest(t, tt.workflow)
req.Force = true
writeCatalogState(t, destinationBackend, "", baseCatalog(req))
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != tt.want || plan.Force || plan.ClearDestinationRoot {
t.Fatalf("plan action=%s force=%t clear=%t, want %s without forced clear", plan.Action, plan.Force, plan.ClearDestinationRoot, tt.want)
}
})
}
}
func TestValidateRequestRejectsInvalidWorkflow(t *testing.T) {
sourceBackend, destinationBackend, req := catalogPlanRequest(t, "append")
req.SourceBackend = sourceBackend
req.DestinationBackend = destinationBackend
err := validateRequest(req)
if err == nil {
t.Fatal("validateRequest() error = nil, want invalid workflow")
}
if !strings.Contains(err.Error(), "destination.workflow") {
t.Fatalf("validateRequest() error = %v, want workflow context", err)
}
}
func catalogPlanRequest(t *testing.T, workflow string) (*fake.Backend, *fake.Backend, Request) {
t.Helper()
sourceBackend := fake.New()
destinationBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{})
return sourceBackend, destinationBackend, Request{
PipelineID: "reports",
DestinationID: "archive",
SourceBundle: sourceBundle,
SourceBackend: sourceBackend,
DestinationBackend: destinationBackend,
DestinationBundlePath: "",
PathMapping: config.PathMappingPreserveRelative,
Publish: config.PublishPolicy{Source: true},
Workflow: workflow,
DistributorVersion: "test",
Now: planUpdatedAt,
}
}
func catalogGeneratedPlanRequest(t *testing.T, workflow string) (*fake.Backend, *fake.Backend, Request) {
t.Helper()
sourceBackend, destinationBackend, req := catalogPlanRequest(t, workflow)
data := []byte("<p>Generated</p>\n")
req.Publish = config.PublishPolicy{HTML: true}
req.Transform = config.Transform{MarkdownToHTML: &config.MarkdownToHTML{
Enabled: true,
Mode: config.TransformModeSidecar,
}}
req.Transformers = testResolver{transform.MarkdownToHTML: testTransformer{outputs: []transform.Output{{
Path: "report.html",
SourcePath: "report.md",
Transform: transform.MarkdownToHTML,
Data: data,
SHA256: bundle.FileDigest(data),
Size: int64(len(data)),
}}}}
return sourceBackend, destinationBackend, req
}
func matchingCatalogForRequest(t *testing.T, req Request) state.CatalogState {
t.Helper()
outputs, err := PlanOutputs(context.Background(), req)
if err != nil {
t.Fatalf("PlanOutputs() error = %v", err)
}
outputs, _, err = PlanLinks(req, outputs)
if err != nil {
t.Fatalf("PlanLinks() error = %v", err)
}
catalog := baseCatalog(req)
catalog.Outputs = catalogOutputsForPlan(req, outputs, nil, state.CurrentOwnerScope(req.PipelineID, req.DestinationID), planCreatedAt)
return catalog
}
func baseCatalog(req Request) state.CatalogState {
return state.CatalogState{
SchemaVersion: state.CatalogSchemaVersion,
DistributorVersion: "previous",
CreatedAt: planCreatedAt,
UpdatedAt: planCreatedAt,
State: state.StatePolicy{Mode: state.StateModeCatalog},
Outputs: []state.CatalogOutputFile{},
}
}
func catalogOutput(req Request, pipelineID, destinationID, path, kind string, createdAt time.Time) state.CatalogOutputFile {
sourceSHA := req.SourceBundle.Manifest.Files[0].SHA256
sourceSize := req.SourceBundle.Manifest.Files[0].Size
for _, file := range req.SourceBundle.Manifest.Files {
if file.Path == path {
sourceSHA = file.SHA256
sourceSize = file.Size
break
}
}
output := state.CatalogOutputFile{
Path: path,
PipelineID: pipelineID,
DestinationID: destinationID,
Source: state.CatalogSourceIdentity{
ID: req.SourceBundle.Manifest.ID,
Digest: req.SourceBundle.Manifest.Digest,
Created: req.SourceBundle.Manifest.Created,
},
Kind: kind,
SHA256: sourceSHA,
Size: sourceSize,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
return output
}
func writeCatalogState(t *testing.T, backend *fake.Backend, relative string, catalog state.CatalogState) {
t.Helper()
writeJSONState(t, backend, relative, catalog)
}
func legacyStateDocument(schemaVersion int) map[string]int {
return map[string]int{"schema_version": schemaVersion}
}
func writeJSONState(t *testing.T, backend *fake.Backend, relative string, value any) {
t.Helper()
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal state: %v", err)
}
statePath, err := storage.StatePath(relative)
if err != nil {
t.Fatalf("state path: %v", err)
}
testutil.WriteFakeFile(t, backend, statePath, string(append(data, '\n')))
}

View File

@@ -14,11 +14,15 @@ func inspectDestination(ctx context.Context, backend storage.Backend, bundlePath
} }
data, err := backend.ReadFile(ctx, statePath) data, err := backend.ReadFile(ctx, statePath)
if err == nil { if err == nil {
destinationState, parseErr := state.Parse(data) document, parseErr := state.ParseDocument(data)
if parseErr != nil { if parseErr != nil {
return state.DestinationStatus{StateErr: parseErr}, nil return state.DestinationStatus{StateErr: parseErr}, nil
} }
return state.DestinationStatus{State: &destinationState, HasContents: true}, nil return state.DestinationStatus{
Catalog: document.Catalog,
SupersededLegacy: document.SupersededLegacy,
HasContents: true,
}, nil
} }
if !storage.IsNotFound(err) { if !storage.IsNotFound(err) {
return state.DestinationStatus{}, err return state.DestinationStatus{}, err

422
internal/state/catalog.go Normal file
View File

@@ -0,0 +1,422 @@
package state
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/link"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
type CatalogState struct {
SchemaVersion int
DistributorVersion string
CreatedAt time.Time
UpdatedAt time.Time
State StatePolicy
Outputs []CatalogOutputFile
}
type CatalogSourceIdentity struct {
ID string
Digest string
Created time.Time
}
type CatalogOutputFile struct {
Path string
PipelineID string
DestinationID string
Source CatalogSourceIdentity
Kind string
SourcePath string
Transform string
URL string
SHA256 string
Size int64
CreatedAt time.Time
UpdatedAt time.Time
}
type rawCatalogState struct {
SchemaVersion *int `json:"schema_version"`
DistributorVersion string `json:"distributor_version"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
State *rawStatePolicy `json:"state"`
Outputs []rawCatalogOutput `json:"outputs"`
}
type rawCatalogOutput struct {
Path *string `json:"path"`
PipelineID *string `json:"pipeline_id"`
DestinationID *string `json:"destination_id"`
Source *rawCatalogSourceIdentity `json:"source"`
Kind *string `json:"kind"`
SourcePath *string `json:"source_path"`
Transform *string `json:"transform"`
URL *string `json:"url"`
SHA256 *string `json:"sha256"`
Size *int64 `json:"size"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}
type rawCatalogSourceIdentity struct {
ID *string `json:"id"`
Digest *string `json:"digest"`
Created *string `json:"created"`
}
func ParseCatalog(data []byte) (CatalogState, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var raw rawCatalogState
if err := decoder.Decode(&raw); err != nil {
return CatalogState{}, fmt.Errorf("parse distributor state: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return CatalogState{}, fmt.Errorf("parse distributor state: trailing data")
}
state, err := parseCatalogRaw(raw)
if err != nil {
return CatalogState{}, err
}
if err := ValidateCatalog(state); err != nil {
return CatalogState{}, err
}
return state, nil
}
func parseCatalogRaw(raw rawCatalogState) (CatalogState, error) {
if raw.SchemaVersion == nil {
return CatalogState{}, fmt.Errorf("state schema_version is required")
}
state := CatalogState{
SchemaVersion: *raw.SchemaVersion,
DistributorVersion: raw.DistributorVersion,
}
if state.SchemaVersion != CatalogSchemaVersion {
return CatalogState{}, fmt.Errorf("state schema_version must be %d", CatalogSchemaVersion)
}
createdAt, err := parseRequiredTime("state created_at", raw.CreatedAt)
if err != nil {
return CatalogState{}, err
}
updatedAt, err := parseRequiredTime("state updated_at", raw.UpdatedAt)
if err != nil {
return CatalogState{}, err
}
state.CreatedAt = createdAt
state.UpdatedAt = updatedAt
if raw.State == nil || raw.State.Mode == "" {
return CatalogState{}, fmt.Errorf("state state.mode is required")
}
state.State.Mode = raw.State.Mode
if raw.Outputs == nil {
return CatalogState{}, fmt.Errorf("state outputs is required")
}
outputs, err := parseCatalogOutputs(raw.Outputs)
if err != nil {
return CatalogState{}, err
}
state.Outputs = outputs
return state, nil
}
func parseCatalogOutputs(rawOutputs []rawCatalogOutput) ([]CatalogOutputFile, error) {
outputs := make([]CatalogOutputFile, 0, len(rawOutputs))
for index, raw := range rawOutputs {
output, err := parseCatalogOutput(index, raw)
if err != nil {
return nil, err
}
outputs = append(outputs, output)
}
return outputs, nil
}
func parseCatalogOutput(index int, raw rawCatalogOutput) (CatalogOutputFile, error) {
if raw.Path == nil || *raw.Path == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].path is required", index)
}
if raw.PipelineID == nil || *raw.PipelineID == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].pipeline_id is required", index)
}
if raw.DestinationID == nil || *raw.DestinationID == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].destination_id is required", index)
}
source, err := parseCatalogSourceIdentity(index, raw.Source)
if err != nil {
return CatalogOutputFile{}, err
}
if raw.Kind == nil || *raw.Kind == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].kind is required", index)
}
switch *raw.Kind {
case OutputKindSource:
if raw.SourcePath != nil {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].source_path is only valid for generated output", index)
}
if raw.Transform != nil {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].transform is only valid for generated output", index)
}
case OutputKindGenerated:
if raw.SourcePath == nil || *raw.SourcePath == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].source_path is required for generated output", index)
}
if raw.Transform == nil || *raw.Transform == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].transform is required for generated output", index)
}
}
if raw.SHA256 == nil || *raw.SHA256 == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].sha256 is required", index)
}
if raw.Size == nil {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].size is required", index)
}
createdAt, err := parseRequiredTime(fmt.Sprintf("state outputs[%d].created_at", index), raw.CreatedAt)
if err != nil {
return CatalogOutputFile{}, err
}
updatedAt, err := parseRequiredTime(fmt.Sprintf("state outputs[%d].updated_at", index), raw.UpdatedAt)
if err != nil {
return CatalogOutputFile{}, err
}
output := CatalogOutputFile{
Path: *raw.Path,
PipelineID: *raw.PipelineID,
DestinationID: *raw.DestinationID,
Source: source,
Kind: *raw.Kind,
SHA256: *raw.SHA256,
Size: *raw.Size,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
if raw.SourcePath != nil {
output.SourcePath = *raw.SourcePath
}
if raw.Transform != nil {
output.Transform = *raw.Transform
}
if raw.URL != nil {
if *raw.URL == "" {
return CatalogOutputFile{}, fmt.Errorf("state outputs[%d].url must not be empty", index)
}
output.URL = *raw.URL
}
return output, nil
}
func parseCatalogSourceIdentity(index int, raw *rawCatalogSourceIdentity) (CatalogSourceIdentity, error) {
if raw == nil {
return CatalogSourceIdentity{}, fmt.Errorf("state outputs[%d].source is required", index)
}
if raw.ID == nil || *raw.ID == "" {
return CatalogSourceIdentity{}, fmt.Errorf("state outputs[%d].source.id is required", index)
}
if raw.Digest == nil || *raw.Digest == "" {
return CatalogSourceIdentity{}, fmt.Errorf("state outputs[%d].source.digest is required", index)
}
created, err := parseRequiredTime(fmt.Sprintf("state outputs[%d].source.created", index), raw.Created)
if err != nil {
return CatalogSourceIdentity{}, err
}
return CatalogSourceIdentity{
ID: *raw.ID,
Digest: *raw.Digest,
Created: created,
}, nil
}
func (s CatalogState) CreatedAtString() string {
return s.CreatedAt.UTC().Format(time.RFC3339)
}
func (s CatalogState) UpdatedAtString() string {
return s.UpdatedAt.UTC().Format(time.RFC3339)
}
func (s CatalogSourceIdentity) CreatedString() string {
return s.Created.UTC().Format(time.RFC3339)
}
func (o CatalogOutputFile) CreatedAtString() string {
return o.CreatedAt.UTC().Format(time.RFC3339)
}
func (o CatalogOutputFile) UpdatedAtString() string {
return o.UpdatedAt.UTC().Format(time.RFC3339)
}
func (s CatalogState) MarshalJSON() ([]byte, error) {
type stateJSON struct {
SchemaVersion int `json:"schema_version"`
DistributorVersion string `json:"distributor_version,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
State StatePolicy `json:"state"`
Outputs []CatalogOutputFile `json:"outputs"`
}
return json.Marshal(stateJSON{
SchemaVersion: s.SchemaVersion,
DistributorVersion: s.DistributorVersion,
CreatedAt: s.CreatedAtString(),
UpdatedAt: s.UpdatedAtString(),
State: s.State,
Outputs: s.Outputs,
})
}
func (s CatalogSourceIdentity) MarshalJSON() ([]byte, error) {
type sourceJSON struct {
ID string `json:"id"`
Digest string `json:"digest"`
Created string `json:"created"`
}
return json.Marshal(sourceJSON{
ID: s.ID,
Digest: s.Digest,
Created: s.CreatedString(),
})
}
func (o CatalogOutputFile) MarshalJSON() ([]byte, error) {
type outputJSON struct {
Path string `json:"path"`
PipelineID string `json:"pipeline_id"`
DestinationID string `json:"destination_id"`
Source CatalogSourceIdentity `json:"source"`
Kind string `json:"kind"`
SourcePath string `json:"source_path,omitempty"`
Transform string `json:"transform,omitempty"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
return json.Marshal(outputJSON{
Path: o.Path,
PipelineID: o.PipelineID,
DestinationID: o.DestinationID,
Source: o.Source,
Kind: o.Kind,
SourcePath: o.SourcePath,
Transform: o.Transform,
URL: o.URL,
SHA256: o.SHA256,
Size: o.Size,
CreatedAt: o.CreatedAtString(),
UpdatedAt: o.UpdatedAtString(),
})
}
func ValidateCatalog(s CatalogState) error {
if s.SchemaVersion != CatalogSchemaVersion {
return fmt.Errorf("state schema_version must be %d", CatalogSchemaVersion)
}
if s.CreatedAt.IsZero() {
return fmt.Errorf("state created_at is required")
}
if s.UpdatedAt.IsZero() {
return fmt.Errorf("state updated_at is required")
}
if s.State.Mode != StateModeCatalog {
return fmt.Errorf("state state.mode must be %s", StateModeCatalog)
}
if s.Outputs == nil {
return fmt.Errorf("state outputs is required")
}
seenPaths := make(map[string]struct{}, len(s.Outputs))
for index, output := range s.Outputs {
if err := validateCatalogOutput(index, output); err != nil {
return err
}
if _, exists := seenPaths[output.Path]; exists {
return fmt.Errorf("state outputs[%d].path duplicates %q", index, output.Path)
}
seenPaths[output.Path] = struct{}{}
}
return nil
}
func validateCatalogOutput(index int, output CatalogOutputFile) error {
if err := storage.ValidatePath(output.Path); err != nil {
return fmt.Errorf("state outputs[%d].path: %w", index, err)
}
if output.PipelineID == "" {
return fmt.Errorf("state outputs[%d].pipeline_id is required", index)
}
if !config.IsSlugLikeID(output.PipelineID) {
return fmt.Errorf("state outputs[%d].pipeline_id must be a slug-like identifier", index)
}
if output.DestinationID == "" {
return fmt.Errorf("state outputs[%d].destination_id is required", index)
}
if !config.IsSlugLikeID(output.DestinationID) {
return fmt.Errorf("state outputs[%d].destination_id must be a slug-like identifier", index)
}
if err := validateCatalogSourceIdentity(index, output.Source); err != nil {
return err
}
switch output.Kind {
case OutputKindSource:
if output.SourcePath != "" {
return fmt.Errorf("state outputs[%d].source_path is only valid for generated output", index)
}
if output.Transform != "" {
return fmt.Errorf("state outputs[%d].transform is only valid for generated output", index)
}
case OutputKindGenerated:
if output.SourcePath == "" {
return fmt.Errorf("state outputs[%d].source_path is required for generated output", index)
}
if err := storage.ValidatePath(output.SourcePath); err != nil {
return fmt.Errorf("state outputs[%d].source_path: %w", index, err)
}
if output.Transform == "" {
return fmt.Errorf("state outputs[%d].transform is required for generated output", index)
}
default:
return fmt.Errorf("state outputs[%d].kind must be source or generated", index)
}
if output.URL != "" {
if err := link.ValidateHTTPURL(output.URL); err != nil {
return fmt.Errorf("state outputs[%d].url: %w", index, err)
}
}
if err := bundle.ValidateDigest(output.SHA256); err != nil {
return fmt.Errorf("state outputs[%d].sha256: %w", index, err)
}
if output.Size < 0 {
return fmt.Errorf("state outputs[%d].size must be non-negative", index)
}
if output.CreatedAt.IsZero() {
return fmt.Errorf("state outputs[%d].created_at is required", index)
}
if output.UpdatedAt.IsZero() {
return fmt.Errorf("state outputs[%d].updated_at is required", index)
}
return nil
}
func validateCatalogSourceIdentity(index int, source CatalogSourceIdentity) error {
if source.ID == "" {
return fmt.Errorf("state outputs[%d].source.id is required", index)
}
if err := bundle.ValidateDigest(source.Digest); err != nil {
return fmt.Errorf("state outputs[%d].source.digest: %w", index, err)
}
if source.Created.IsZero() {
return fmt.Errorf("state outputs[%d].source.created is required", index)
}
return nil
}

View File

@@ -0,0 +1,398 @@
package state
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
)
func TestParseCatalogState(t *testing.T) {
state, err := ParseCatalog([]byte(validCatalogStateJSON(t)))
if err != nil {
t.Fatalf("ParseCatalog() error = %v", err)
}
if got, want := state.SchemaVersion, CatalogSchemaVersion; got != want {
t.Fatalf("schema version = %d, want %d", got, want)
}
if got, want := state.CreatedAtString(), "2026-06-19T12:00:00Z"; got != want {
t.Fatalf("created_at = %q, want %q", got, want)
}
if got, want := state.UpdatedAtString(), "2026-06-19T12:05:00Z"; got != want {
t.Fatalf("updated_at = %q, want %q", got, want)
}
if got, want := state.State.Mode, StateModeCatalog; got != want {
t.Fatalf("state mode = %q, want %q", got, want)
}
if got, want := len(state.Outputs), 2; got != want {
t.Fatalf("output count = %d, want %d", got, want)
}
sourceOutput := state.Outputs[0]
if sourceOutput.SourcePath != "" || sourceOutput.Transform != "" {
t.Fatalf("source output source_path=%q transform=%q, want omitted", sourceOutput.SourcePath, sourceOutput.Transform)
}
generatedOutput := state.Outputs[1]
if generatedOutput.SourcePath != "report.md" || generatedOutput.Transform != "markdown_to_html" {
t.Fatalf("generated output source_path=%q transform=%q", generatedOutput.SourcePath, generatedOutput.Transform)
}
if got, want := generatedOutput.Source.CreatedString(), "2026-05-30T11:10:00Z"; got != want {
t.Fatalf("source created = %q, want %q", got, want)
}
}
func TestCatalogMarshalIsDeterministic(t *testing.T) {
data, err := json.Marshal(validCatalogState(t))
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
want := `{"schema_version":4,"distributor_version":"dev","created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z","state":{"mode":"catalog"},"outputs":[{"path":"report.md","pipeline_id":"reports","destination_id":"archive","source":{"id":"weather.daily.brentwood.2026-05-30","digest":"sha256:c5590c36194c1307f20f85a64f2abe7b4769b2b1e4696d3753dba6f84011658f","created":"2026-05-30T11:10:00Z"},"kind":"source","sha256":"sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6","size":16,"created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z"},{"path":"report.html","pipeline_id":"reports","destination_id":"html","source":{"id":"weather.daily.brentwood.2026-05-30","digest":"sha256:c5590c36194c1307f20f85a64f2abe7b4769b2b1e4696d3753dba6f84011658f","created":"2026-05-30T11:10:00Z"},"kind":"generated","source_path":"report.md","transform":"markdown_to_html","url":"https://reports.example.com/report.html","sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":128,"created_at":"2026-06-19T12:00:00Z","updated_at":"2026-06-19T12:05:00Z"}]}`
if string(data) != want {
t.Fatalf("json = %s, want %s", data, want)
}
}
func TestParseDocumentHandlesCatalogAndSupersededLegacy(t *testing.T) {
catalog, err := ParseDocument([]byte(validCatalogStateJSON(t)))
if err != nil {
t.Fatalf("ParseDocument(catalog) error = %v", err)
}
if catalog.Catalog == nil || catalog.SupersededLegacy != nil {
t.Fatalf("catalog document = %#v", catalog)
}
tests := map[string]string{
"schema 1": schemaOnlyStateJSON(legacySchemaVersion),
"schema 2": schemaOnlyStateJSON(legacyOwnerSchema),
"schema 3": schemaOnlyStateJSON(legacyMultiOwnerSchema),
}
for name, body := range tests {
t.Run(name, func(t *testing.T) {
document, err := ParseDocument([]byte(body))
if err != nil {
t.Fatalf("ParseDocument() error = %v", err)
}
if document.SupersededLegacy == nil || document.Catalog != nil {
t.Fatalf("document = %#v, want superseded legacy only", document)
}
if document.SupersededLegacy.SchemaVersion < legacySchemaVersion || document.SupersededLegacy.SchemaVersion >= CatalogSchemaVersion {
t.Fatalf("legacy schema version = %d, want 1 through 3", document.SupersededLegacy.SchemaVersion)
}
})
}
}
func TestParseDocumentRejectsUnsupportedFutureSchema(t *testing.T) {
body := strings.Replace(validCatalogStateJSON(t), `"schema_version": 4`, `"schema_version": 5`, 1)
_, err := ParseDocument([]byte(body))
assertStateErrorContains(t, err, "schema_version 5 is unsupported")
}
func TestParseDocumentRejectsTrailingData(t *testing.T) {
_, err := ParseDocument([]byte(validCatalogStateJSON(t) + "\n{}"))
assertStateErrorContains(t, err, "trailing data")
}
func TestParseCatalogRejectsMissingFields(t *testing.T) {
tests := map[string]func(map[string]any){
"schema_version": func(document map[string]any) {
delete(document, "schema_version")
},
"created_at": func(document map[string]any) {
delete(document, "created_at")
},
"updated_at": func(document map[string]any) {
delete(document, "updated_at")
},
"state": func(document map[string]any) {
delete(document, "state")
},
"outputs": func(document map[string]any) {
delete(document, "outputs")
},
"path": func(document map[string]any) {
delete(firstCatalogOutput(document), "path")
},
"pipeline_id": func(document map[string]any) {
delete(firstCatalogOutput(document), "pipeline_id")
},
"destination_id": func(document map[string]any) {
delete(firstCatalogOutput(document), "destination_id")
},
"source": func(document map[string]any) {
delete(firstCatalogOutput(document), "source")
},
"source id": func(document map[string]any) {
delete(firstCatalogSource(document), "id")
},
"source digest": func(document map[string]any) {
delete(firstCatalogSource(document), "digest")
},
"source created": func(document map[string]any) {
delete(firstCatalogSource(document), "created")
},
"kind": func(document map[string]any) {
delete(firstCatalogOutput(document), "kind")
},
"sha256": func(document map[string]any) {
delete(firstCatalogOutput(document), "sha256")
},
"size": func(document map[string]any) {
delete(firstCatalogOutput(document), "size")
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
document := catalogStateObject(t)
mutate(document)
_, err := ParseCatalog(mustMarshalCatalogObject(t, document))
assertStateErrorContains(t, err, "required")
})
}
}
func TestParseCatalogRejectsMalformedTimestamps(t *testing.T) {
tests := map[string]func(string) string{
"created_at": func(body string) string {
return strings.Replace(body, `"created_at": "2026-06-19T12:00:00Z"`, `"created_at": "June 19"`, 1)
},
"updated_at": func(body string) string {
return strings.Replace(body, `"updated_at": "2026-06-19T12:05:00Z"`, `"updated_at": "June 19"`, 1)
},
"source created": func(body string) string {
return strings.Replace(body, `"created": "2026-05-30T11:10:00Z"`, `"created": "May 30"`, 1)
},
"output created_at": func(body string) string {
return strings.Replace(body, ` "created_at": "2026-06-19T12:00:00Z"`, ` "created_at": "June 19"`, 1)
},
"output updated_at": func(body string) string {
return strings.Replace(body, ` "updated_at": "2026-06-19T12:05:00Z"`, ` "updated_at": "June 19"`, 1)
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
_, err := ParseCatalog([]byte(mutate(validCatalogStateJSON(t))))
assertStateErrorContains(t, err, "RFC3339")
})
}
}
func TestValidateCatalogRejectsInvalidOutputRecords(t *testing.T) {
tests := map[string]func(*CatalogState){
"duplicate path": func(s *CatalogState) {
s.Outputs[1].Path = s.Outputs[0].Path
},
"invalid output path": func(s *CatalogState) {
s.Outputs[0].Path = "../report.md"
},
"invalid pipeline id": func(s *CatalogState) {
s.Outputs[0].PipelineID = ".reports"
},
"invalid destination id": func(s *CatalogState) {
s.Outputs[0].DestinationID = ".archive"
},
"missing source id": func(s *CatalogState) {
s.Outputs[0].Source.ID = ""
},
"invalid source digest": func(s *CatalogState) {
s.Outputs[0].Source.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"
},
"missing source created": func(s *CatalogState) {
s.Outputs[0].Source.Created = time.Time{}
},
"invalid kind": func(s *CatalogState) {
s.Outputs[0].Kind = "document"
},
"generated missing source path": func(s *CatalogState) {
s.Outputs[1].SourcePath = ""
},
"generated invalid source path": func(s *CatalogState) {
s.Outputs[1].SourcePath = "../report.md"
},
"generated missing transform": func(s *CatalogState) {
s.Outputs[1].Transform = ""
},
"source output source path": func(s *CatalogState) {
s.Outputs[0].SourcePath = "report.md"
},
"source output transform": func(s *CatalogState) {
s.Outputs[0].Transform = "markdown_to_html"
},
"invalid output digest": func(s *CatalogState) {
s.Outputs[0].SHA256 = "SHA256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6"
},
"negative size": func(s *CatalogState) {
s.Outputs[0].Size = -1
},
"invalid url": func(s *CatalogState) {
s.Outputs[1].URL = "file:///tmp/report.html"
},
"missing created at": func(s *CatalogState) {
s.Outputs[0].CreatedAt = time.Time{}
},
"missing updated at": func(s *CatalogState) {
s.Outputs[0].UpdatedAt = time.Time{}
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
state := validCatalogState(t)
mutate(&state)
if err := ValidateCatalog(state); err == nil {
t.Fatal("ValidateCatalog() error = nil, want error")
}
})
}
}
func TestParseCatalogRejectsForbiddenFields(t *testing.T) {
tests := map[string]string{
"owners": `"owners": [],`,
"sources": `"sources": [],`,
"workflow": `"workflow": "additive",`,
"source": `"source": {"manifest": {}},`,
"manifest": `"manifest": {},`,
"pipeline_id": `"pipeline_id": "reports",`,
"destination_id": `"destination_id": "archive",`,
"published_at": `"published_at": "2026-06-19T12:00:00Z",`,
}
for name, field := range tests {
t.Run(name, func(t *testing.T) {
body := strings.Replace(validCatalogStateJSON(t), `"created_at":`, field+"\n "+`"created_at":`, 1)
_, err := ParseCatalog([]byte(body))
assertStateErrorContains(t, err, "unknown field")
})
}
}
func TestParseCatalogRejectsForbiddenOutputFieldsForSourceOutput(t *testing.T) {
tests := map[string]string{
"source_path": `"source_path": "report.md",`,
"transform": `"transform": "markdown_to_html",`,
"empty url": `"url": "",`,
}
for name, field := range tests {
t.Run(name, func(t *testing.T) {
body := strings.Replace(validCatalogStateJSON(t), `"kind": "source",`, `"kind": "source",`+"\n "+field, 1)
_, err := ParseCatalog([]byte(body))
if err == nil {
t.Fatal("ParseCatalog() error = nil, want error")
}
})
}
}
func validCatalogStateJSON(t *testing.T) string {
t.Helper()
data, err := json.MarshalIndent(validCatalogState(t), "", " ")
if err != nil {
t.Fatalf("marshal catalog state: %v", err)
}
return string(data)
}
func catalogStateObject(t *testing.T) map[string]any {
t.Helper()
var document map[string]any
if err := json.Unmarshal([]byte(validCatalogStateJSON(t)), &document); err != nil {
t.Fatalf("unmarshal catalog state: %v", err)
}
return document
}
func firstCatalogOutput(document map[string]any) map[string]any {
outputs := document["outputs"].([]any)
return outputs[0].(map[string]any)
}
func firstCatalogSource(document map[string]any) map[string]any {
return firstCatalogOutput(document)["source"].(map[string]any)
}
func mustMarshalCatalogObject(t *testing.T, document map[string]any) []byte {
t.Helper()
data, err := json.Marshal(document)
if err != nil {
t.Fatalf("marshal catalog object: %v", err)
}
return data
}
func schemaOnlyStateJSON(schemaVersion int) string {
data, err := json.Marshal(map[string]int{"schema_version": schemaVersion})
if err != nil {
panic(err)
}
return string(data)
}
func assertStateErrorContains(t *testing.T, err error, substring string) {
t.Helper()
if err == nil {
t.Fatalf("error = nil, want substring %q", substring)
}
if !strings.Contains(err.Error(), substring) {
t.Fatalf("error = %v, want substring %q", err, substring)
}
}
func validManifest(t *testing.T) bundle.Manifest {
t.Helper()
files := []bundle.ManifestFile{{
Path: "report.md",
SHA256: bundle.FileDigest([]byte("# Report\nSunny.\n")),
Size: int64(len("# Report\nSunny.\n")),
}}
manifest := bundle.Manifest{
SchemaVersion: bundle.SchemaVersion,
ID: "weather.daily.brentwood.2026-05-30",
Created: time.Date(2026, 5, 30, 11, 10, 0, 0, time.UTC),
Files: files,
}
manifest.Digest = bundle.BundleDigest(manifest.Files)
return manifest
}
func validCatalogState(t *testing.T) CatalogState {
t.Helper()
manifest := validManifest(t)
createdAt := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, 6, 19, 12, 5, 0, 0, time.UTC)
source := CatalogSourceIdentity{
ID: manifest.ID,
Digest: manifest.Digest,
Created: manifest.Created,
}
return CatalogState{
SchemaVersion: CatalogSchemaVersion,
DistributorVersion: "dev",
CreatedAt: createdAt,
UpdatedAt: updatedAt,
State: StatePolicy{Mode: StateModeCatalog},
Outputs: []CatalogOutputFile{{
Path: "report.md",
PipelineID: "reports",
DestinationID: "archive",
Source: source,
Kind: OutputKindSource,
SHA256: manifest.Files[0].SHA256,
Size: manifest.Files[0].Size,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, {
Path: "report.html",
PipelineID: "reports",
DestinationID: "html",
Source: source,
Kind: OutputKindGenerated,
SourcePath: "report.md",
Transform: "markdown_to_html",
URL: "https://reports.example.com/report.html",
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Size: 128,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}},
}
}

View File

@@ -1,89 +0,0 @@
package state
import (
"fmt"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
)
type Outcome string
const (
OutcomeDestinationAbsent Outcome = "destination_absent"
OutcomeDestinationUnmanaged Outcome = "destination_unmanaged"
OutcomeInvalidState Outcome = "invalid_destination_state"
OutcomeIdentityMismatch Outcome = "destination_identity_mismatch"
OutcomeSameSource Outcome = "same_source_manifest"
OutcomeDestinationOlder Outcome = "destination_older"
OutcomeDestinationNewer Outcome = "destination_newer"
OutcomeSameCreatedConflict Outcome = "same_created_digest_conflict"
OutcomeDifferentSourceConflict Outcome = "different_source_conflict"
)
type DestinationStatus struct {
State *DistributorState
StateErr error
HasContents bool
}
type Comparison struct {
Outcome Outcome
Reason string
}
func Compare(source bundle.Manifest, pipelineID, destinationID string, status DestinationStatus) Comparison {
if status.StateErr != nil {
return Comparison{Outcome: OutcomeInvalidState, Reason: status.StateErr.Error()}
}
if status.State == nil {
if status.HasContents {
return Comparison{Outcome: OutcomeDestinationUnmanaged, Reason: "destination has content but no distributor state"}
}
return Comparison{Outcome: OutcomeDestinationAbsent, Reason: "destination state is absent"}
}
destinationState := *status.State
if err := Validate(destinationState); err != nil {
return Comparison{Outcome: OutcomeInvalidState, Reason: err.Error()}
}
if destinationState.PipelineID != pipelineID {
return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("pipeline id %q does not match %q", destinationState.PipelineID, pipelineID)}
}
if destinationState.DestinationID != destinationID {
return Comparison{Outcome: OutcomeIdentityMismatch, Reason: fmt.Sprintf("destination id %q does not match %q", destinationState.DestinationID, destinationID)}
}
destinationManifest := destinationState.Source.Manifest
if manifestsEqual(source, destinationManifest) {
return Comparison{Outcome: OutcomeSameSource, Reason: "destination source manifest matches source"}
}
if destinationManifest.ID != source.ID {
return Comparison{Outcome: OutcomeDifferentSourceConflict, Reason: "destination source id differs from source"}
}
if destinationManifest.Created.Before(source.Created) {
return Comparison{Outcome: OutcomeDestinationOlder, Reason: "destination source is older than source"}
}
if destinationManifest.Created.After(source.Created) {
return Comparison{Outcome: OutcomeDestinationNewer, Reason: "destination source is newer than source"}
}
if destinationManifest.Digest != source.Digest {
return Comparison{Outcome: OutcomeSameCreatedConflict, Reason: "destination source has same id and created time but different digest"}
}
return Comparison{Outcome: OutcomeInvalidState, Reason: "destination source differs from source without a supported comparison outcome"}
}
func manifestsEqual(a, b bundle.Manifest) bool {
if a.SchemaVersion != b.SchemaVersion ||
a.ID != b.ID ||
a.Digest != b.Digest ||
!a.Created.Equal(b.Created) ||
len(a.Files) != len(b.Files) {
return false
}
for index := range a.Files {
if a.Files[index] != b.Files[index] {
return false
}
}
return true
}

View File

@@ -1,121 +0,0 @@
package state
import (
"errors"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
)
func TestCompareOutcomes(t *testing.T) {
source := validManifest(t)
tests := []struct {
name string
status DestinationStatus
want Outcome
}{
{
name: "destination absent",
status: DestinationStatus{},
want: OutcomeDestinationAbsent,
},
{
name: "destination unmanaged",
status: DestinationStatus{HasContents: true},
want: OutcomeDestinationUnmanaged,
},
{
name: "invalid destination state",
status: DestinationStatus{StateErr: errors.New("invalid json")},
want: OutcomeInvalidState,
},
{
name: "pipeline mismatch",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.PipelineID = "other" })},
want: OutcomeIdentityMismatch,
},
{
name: "destination mismatch",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) { s.DestinationID = "other" })},
want: OutcomeIdentityMismatch,
},
{
name: "same source manifest",
status: DestinationStatus{State: withState(t, source, nil)},
want: OutcomeSameSource,
},
{
name: "destination older",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
s.Source.Manifest.Created = source.Created.Add(-time.Hour)
})},
want: OutcomeDestinationOlder,
},
{
name: "destination newer",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
s.Source.Manifest.Created = source.Created.Add(time.Hour)
})},
want: OutcomeDestinationNewer,
},
{
name: "same created digest conflict",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
s.Source.Manifest.Files[0].SHA256 = "sha256:3333333333333333333333333333333333333333333333333333333333333333"
s.Source.Manifest.Digest = bundle.BundleDigest(s.Source.Manifest.Files)
})},
want: OutcomeSameCreatedConflict,
},
{
name: "different source id",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
s.Source.Manifest.ID = "other.source"
})},
want: OutcomeDifferentSourceConflict,
},
{
name: "invalid state object",
status: DestinationStatus{State: withState(t, source, func(s *DistributorState) {
s.Outputs[0].Kind = "other"
})},
want: OutcomeInvalidState,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Compare(source, "reports", "archive", tt.status)
if got.Outcome != tt.want {
t.Fatalf("Compare() outcome = %s reason=%q, want %s", got.Outcome, got.Reason, tt.want)
}
if got.Reason == "" {
t.Fatal("Compare() reason is empty")
}
})
}
}
func withState(t *testing.T, source bundle.Manifest, mutate func(*DistributorState)) *DistributorState {
t.Helper()
stateManifest := source
stateManifest.Files = append([]bundle.ManifestFile(nil), source.Files...)
state := DistributorState{
SchemaVersion: SchemaVersion,
PipelineID: "reports",
DestinationID: "archive",
PublishedAt: time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC),
Source: SourceState{Manifest: stateManifest},
Outputs: []OutputFile{{
Path: "report.md",
Kind: OutputKindSource,
SourcePath: "report.md",
SHA256: source.Files[0].SHA256,
Size: source.Files[0].Size,
}},
}
if mutate != nil {
mutate(&state)
}
return &state
}

View File

@@ -1,241 +0,0 @@
package state
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
)
const SchemaVersion = 1
type DistributorState struct {
SchemaVersion int
DistributorVersion string
PipelineID string
DestinationID string
PublishedAt time.Time
Source SourceState
Links *LinkState
Outputs []OutputFile
}
type SourceState struct {
Manifest bundle.Manifest
}
type LinkState struct {
PrimaryURL string
}
type OutputFile struct {
Path string
Kind string
SourcePath string
Transform string
URL string
SHA256 string
Size int64
}
type rawDistributorState struct {
SchemaVersion *int `json:"schema_version"`
DistributorVersion string `json:"distributor_version"`
PipelineID *string `json:"pipeline_id"`
DestinationID *string `json:"destination_id"`
PublishedAt *string `json:"published_at"`
Source *rawSourceState `json:"source"`
Links *rawLinkState `json:"links"`
Outputs []rawOutputFile `json:"outputs"`
}
type rawSourceState struct {
Manifest json.RawMessage `json:"manifest"`
}
type rawLinkState struct {
PrimaryURL string `json:"primary_url"`
}
type rawOutputFile struct {
Path *string `json:"path"`
Kind *string `json:"kind"`
SourcePath *string `json:"source_path"`
Transform string `json:"transform"`
URL string `json:"url"`
SHA256 *string `json:"sha256"`
Size *int64 `json:"size"`
}
func Parse(data []byte) (DistributorState, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
var raw rawDistributorState
if err := decoder.Decode(&raw); err != nil {
return DistributorState{}, fmt.Errorf("parse distributor state: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return DistributorState{}, fmt.Errorf("parse distributor state: trailing data")
}
state, err := parseRaw(raw)
if err != nil {
return DistributorState{}, err
}
if err := Validate(state); err != nil {
return DistributorState{}, err
}
return state, nil
}
func parseRaw(raw rawDistributorState) (DistributorState, error) {
var state DistributorState
if raw.SchemaVersion == nil {
return DistributorState{}, fmt.Errorf("state schema_version is required")
}
state.SchemaVersion = *raw.SchemaVersion
if state.SchemaVersion != SchemaVersion {
return DistributorState{}, fmt.Errorf("state schema_version must be %d", SchemaVersion)
}
state.DistributorVersion = raw.DistributorVersion
if raw.PipelineID == nil || *raw.PipelineID == "" {
return DistributorState{}, fmt.Errorf("state pipeline_id is required")
}
state.PipelineID = *raw.PipelineID
if raw.DestinationID == nil || *raw.DestinationID == "" {
return DistributorState{}, fmt.Errorf("state destination_id is required")
}
state.DestinationID = *raw.DestinationID
if raw.PublishedAt == nil || *raw.PublishedAt == "" {
return DistributorState{}, fmt.Errorf("state published_at is required")
}
publishedAt, err := time.Parse(time.RFC3339, *raw.PublishedAt)
if err != nil {
return DistributorState{}, fmt.Errorf("state published_at must be RFC3339: %w", err)
}
state.PublishedAt = publishedAt.UTC()
if raw.Source == nil || len(raw.Source.Manifest) == 0 {
return DistributorState{}, fmt.Errorf("state source.manifest is required")
}
manifest, err := bundle.ParseManifest(raw.Source.Manifest)
if err != nil {
return DistributorState{}, fmt.Errorf("state source.manifest: %w", err)
}
state.Source.Manifest = manifest
if raw.Links != nil {
state.Links = &LinkState{PrimaryURL: raw.Links.PrimaryURL}
}
if raw.Outputs == nil {
return DistributorState{}, fmt.Errorf("state outputs is required")
}
outputs, err := parseOutputs(raw.Outputs)
if err != nil {
return DistributorState{}, err
}
state.Outputs = outputs
return state, nil
}
func parseOutputs(rawOutputs []rawOutputFile) ([]OutputFile, error) {
outputs := make([]OutputFile, 0, len(rawOutputs))
seen := make(map[string]struct{}, len(rawOutputs))
for index, raw := range rawOutputs {
output, err := parseOutput(index, raw)
if err != nil {
return nil, err
}
if _, exists := seen[output.Path]; exists {
return nil, fmt.Errorf("state outputs[%d].path duplicates %q", index, output.Path)
}
seen[output.Path] = struct{}{}
outputs = append(outputs, output)
}
return outputs, nil
}
func parseOutput(index int, raw rawOutputFile) (OutputFile, error) {
if raw.Path == nil || *raw.Path == "" {
return OutputFile{}, fmt.Errorf("state outputs[%d].path is required", index)
}
if raw.Kind == nil || *raw.Kind == "" {
return OutputFile{}, fmt.Errorf("state outputs[%d].kind is required", index)
}
if raw.SourcePath == nil || *raw.SourcePath == "" {
return OutputFile{}, fmt.Errorf("state outputs[%d].source_path is required", index)
}
if raw.SHA256 == nil || *raw.SHA256 == "" {
return OutputFile{}, fmt.Errorf("state outputs[%d].sha256 is required", index)
}
if raw.Size == nil {
return OutputFile{}, fmt.Errorf("state outputs[%d].size is required", index)
}
return OutputFile{
Path: *raw.Path,
Kind: *raw.Kind,
SourcePath: *raw.SourcePath,
Transform: raw.Transform,
URL: raw.URL,
SHA256: *raw.SHA256,
Size: *raw.Size,
}, nil
}
func (s DistributorState) PublishedAtString() string {
return s.PublishedAt.UTC().Format(time.RFC3339)
}
func (s DistributorState) MarshalJSON() ([]byte, error) {
type sourceJSON struct {
Manifest bundle.Manifest `json:"manifest"`
}
type stateJSON struct {
SchemaVersion int `json:"schema_version"`
DistributorVersion string `json:"distributor_version,omitempty"`
PipelineID string `json:"pipeline_id"`
DestinationID string `json:"destination_id"`
PublishedAt string `json:"published_at"`
Source sourceJSON `json:"source"`
Links *LinkState `json:"links,omitempty"`
Outputs []OutputFile `json:"outputs"`
}
return json.Marshal(stateJSON{
SchemaVersion: s.SchemaVersion,
DistributorVersion: s.DistributorVersion,
PipelineID: s.PipelineID,
DestinationID: s.DestinationID,
PublishedAt: s.PublishedAtString(),
Source: sourceJSON{Manifest: s.Source.Manifest},
Links: s.Links,
Outputs: s.Outputs,
})
}
func (l LinkState) MarshalJSON() ([]byte, error) {
type linkJSON struct {
PrimaryURL string `json:"primary_url,omitempty"`
}
return json.Marshal(linkJSON{PrimaryURL: l.PrimaryURL})
}
func (o OutputFile) MarshalJSON() ([]byte, error) {
type outputJSON struct {
Path string `json:"path"`
Kind string `json:"kind"`
SourcePath string `json:"source_path"`
Transform string `json:"transform,omitempty"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
return json.Marshal(outputJSON{
Path: o.Path,
Kind: o.Kind,
SourcePath: o.SourcePath,
Transform: o.Transform,
URL: o.URL,
SHA256: o.SHA256,
Size: o.Size,
})
}

View File

@@ -1,314 +0,0 @@
package state
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
)
func TestParseValidState(t *testing.T) {
state, err := Parse([]byte(validStateJSON(t)))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if state.SchemaVersion != SchemaVersion {
t.Fatalf("schema version = %d, want %d", state.SchemaVersion, SchemaVersion)
}
if state.PipelineID != "reports" || state.DestinationID != "archive" {
t.Fatalf("identity = %q/%q", state.PipelineID, state.DestinationID)
}
if got, want := state.PublishedAtString(), "2026-05-30T11:12:00Z"; got != want {
t.Fatalf("PublishedAtString() = %q, want %q", got, want)
}
if got, want := len(state.Outputs), 1; got != want {
t.Fatalf("output count = %d, want %d", got, want)
}
}
func TestParseValidStateWithLinks(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"outputs": [`, `"links": {"primary_url": "https://reports.example.com/archive/report.md"},`+"\n "+`"outputs": [`, 1)
body = strings.Replace(body, `"source_path": "report.md",`, `"source_path": "report.md",`+"\n "+`"url": "https://reports.example.com/archive/report.md",`, 1)
state, err := Parse([]byte(body))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if state.Links == nil || state.Links.PrimaryURL != "https://reports.example.com/archive/report.md" {
t.Fatalf("links = %#v, want primary URL", state.Links)
}
if state.Outputs[0].URL != "https://reports.example.com/archive/report.md" {
t.Fatalf("output URL = %q", state.Outputs[0].URL)
}
}
func TestParseNormalizesPublishedAtOffset(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "2026-05-30T13:12:00+02:00"`, 1)
state, err := Parse([]byte(body))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got, want := state.PublishedAtString(), "2026-05-30T11:12:00Z"; got != want {
t.Fatalf("PublishedAtString() = %q, want %q", got, want)
}
}
func TestParseRejectsMissingFields(t *testing.T) {
tests := map[string]string{
"schema_version": `"schema_version"`,
"pipeline_id": `"pipeline_id"`,
"destination_id": `"destination_id"`,
"published_at": `"published_at"`,
"source": `"source"`,
"outputs": `"outputs"`,
}
for name, field := range tests {
t.Run(name, func(t *testing.T) {
body := strings.Replace(validStateJSON(t), field, `"missing_`+name+`"`, 1)
_, err := Parse([]byte(body))
assertStateErrorContains(t, err, "required")
})
}
}
func TestParseRejectsInvalidSchemaVersion(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"schema_version": 1`, `"schema_version": 2`, 1)
_, err := Parse([]byte(body))
assertStateErrorContains(t, err, "schema_version must be 1")
}
func TestParseRejectsInvalidEmbeddedManifest(t *testing.T) {
body := validStateWithManifestJSON(t, strings.Replace(manifestJSON(t), `"schema_version": 1`, `"schema_version": 2`, 1))
_, err := Parse([]byte(body))
assertStateErrorContains(t, err, "source.manifest")
}
func TestValidateRejectsInvalidEmbeddedManifest(t *testing.T) {
tests := map[string]func(bundle.Manifest) bundle.Manifest{
"schema version": func(manifest bundle.Manifest) bundle.Manifest {
manifest.SchemaVersion = 2
return manifest
},
"empty id": func(manifest bundle.Manifest) bundle.Manifest {
manifest.ID = ""
return manifest
},
"bad digest": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Digest = "SHA256:099b205780d2b050024868399961b05731729a548d5d6329c7b06a6740dd75fe"
return manifest
},
"zero created": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Created = time.Time{}
return manifest
},
"empty files": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Files = nil
manifest.Digest = bundle.BundleDigest(manifest.Files)
return manifest
},
"unsafe path": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Files[0].Path = "../report.md"
manifest.Digest = bundle.BundleDigest(manifest.Files)
return manifest
},
"duplicate path": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Files[1].Path = manifest.Files[0].Path
manifest.Digest = bundle.BundleDigest(manifest.Files)
return manifest
},
"negative size": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Files[0].Size = -1
manifest.Digest = bundle.BundleDigest(manifest.Files)
return manifest
},
"digest mismatch": func(manifest bundle.Manifest) bundle.Manifest {
manifest.Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
return manifest
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
source := mutate(validManifest(t))
state := *withState(t, validManifest(t), func(*DistributorState) {})
state.Source.Manifest = source
err := Validate(state)
assertStateErrorContains(t, err, "source.manifest")
})
}
}
func TestParseRejectsInvalidOutputMetadata(t *testing.T) {
source := validManifest(t)
tests := map[string]func(*DistributorState){
"unsafe path": func(s *DistributorState) {
s.Outputs[0].Path = "../report.md"
},
"invalid kind": func(s *DistributorState) {
s.Outputs[0].Kind = "other"
},
"invalid source": func(s *DistributorState) {
s.Outputs[0].SourcePath = "../report.md"
},
"generated missing": func(s *DistributorState) {
s.Outputs[0].Kind = OutputKindGenerated
},
"invalid digest": func(s *DistributorState) {
s.Outputs[0].SHA256 = "SHA256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6"
},
"negative size": func(s *DistributorState) {
s.Outputs[0].Size = -1
},
"invalid output url": func(s *DistributorState) {
s.Outputs[0].URL = "file:///tmp/report.md"
},
"invalid primary url": func(s *DistributorState) {
s.Links = &LinkState{PrimaryURL: "file:///tmp/report.md"}
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
state := *withState(t, source, mutate)
err := Validate(state)
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
})
}
}
func TestValidateReportsURLFieldContext(t *testing.T) {
source := validManifest(t)
state := *withState(t, source, func(s *DistributorState) {
s.Links = &LinkState{PrimaryURL: "https://reports.example.com/archive#top"}
})
err := Validate(state)
assertStateErrorContains(t, err, "state links.primary_url")
assertStateErrorContains(t, err, "must not include a fragment")
state = *withState(t, source, func(s *DistributorState) {
s.Outputs[0].URL = "https://reports.example.com/archive?preview=1"
})
err = Validate(state)
assertStateErrorContains(t, err, "state outputs[0].url")
assertStateErrorContains(t, err, "must not include a query string")
}
func TestParseRejectsMalformedPublishedTimestamp(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "May 30"`, 1)
_, err := Parse([]byte(body))
assertStateErrorContains(t, err, "published_at must be RFC3339")
}
func TestMarshalNormalizesPublishedAtUTC(t *testing.T) {
source := validManifest(t)
state := DistributorState{
SchemaVersion: SchemaVersion,
PipelineID: "reports",
DestinationID: "archive",
PublishedAt: time.Date(2026, 5, 30, 13, 12, 0, 0, time.FixedZone("offset", 2*60*60)),
Source: SourceState{Manifest: source},
Outputs: []OutputFile{{
Path: "report.md",
Kind: OutputKindSource,
SourcePath: "report.md",
SHA256: source.Files[0].SHA256,
Size: source.Files[0].Size,
}},
}
data, err := json.Marshal(state)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(data), `"published_at":"2026-05-30T11:12:00Z"`) {
t.Fatalf("json = %s, want UTC RFC3339 published_at", data)
}
}
func TestMarshalIncludesLinksWhenPresent(t *testing.T) {
source := validManifest(t)
state := DistributorState{
SchemaVersion: SchemaVersion,
PipelineID: "reports",
DestinationID: "archive",
PublishedAt: time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC),
Source: SourceState{Manifest: source},
Links: &LinkState{PrimaryURL: "https://reports.example.com/archive/report.md"},
Outputs: []OutputFile{{
Path: "report.md",
Kind: OutputKindSource,
SourcePath: "report.md",
URL: "https://reports.example.com/archive/report.md",
SHA256: source.Files[0].SHA256,
Size: source.Files[0].Size,
}},
}
data, err := json.Marshal(state)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(data), `"links":{"primary_url":"https://reports.example.com/archive/report.md"}`) {
t.Fatalf("json = %s, want links primary URL", data)
}
if !strings.Contains(string(data), `"url":"https://reports.example.com/archive/report.md"`) {
t.Fatalf("json = %s, want output URL", data)
}
}
func validStateJSON(t *testing.T) string {
t.Helper()
return validStateWithManifestJSON(t, manifestJSON(t))
}
func validStateWithManifestJSON(t *testing.T, manifest string) string {
t.Helper()
return `{
"schema_version": 1,
"distributor_version": "dev",
"pipeline_id": "reports",
"destination_id": "archive",
"published_at": "2026-05-30T11:12:00Z",
"source": {
"manifest": ` + manifest + `
},
"outputs": [
{
"path": "report.md",
"kind": "source",
"source_path": "report.md",
"sha256": "sha256:3640fd37140ee4d2e0e93e78834f232ea67a50e7bc6279203690cc7de1975fa6",
"size": 16
}
]
}`
}
func manifestJSON(t *testing.T) string {
t.Helper()
data, err := os.ReadFile("../bundle/testdata/valid_bundle/manifest.json")
if err != nil {
t.Fatalf("read manifest fixture: %v", err)
}
return string(data)
}
func validManifest(t *testing.T) bundle.Manifest {
t.Helper()
manifest, err := bundle.ParseManifest([]byte(manifestJSON(t)))
if err != nil {
t.Fatalf("ParseManifest() error = %v", err)
}
return manifest
}
func assertStateErrorContains(t *testing.T, err error, want string) {
t.Helper()
if err == nil {
t.Fatalf("error = nil, want substring %q", want)
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want substring %q", err.Error(), want)
}
}

107
internal/state/document.go Normal file
View File

@@ -0,0 +1,107 @@
package state
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
)
const (
legacyOwnerSchema = 2
legacyMultiOwnerSchema = 3
CatalogSchemaVersion = 4
legacySchemaVersion = 1
StateModeCatalog = "catalog"
OutputKindSource = "source"
OutputKindGenerated = "generated"
)
type StateDocument struct {
Catalog *CatalogState
SupersededLegacy *SupersededLegacyState
}
type DestinationStatus struct {
Catalog *CatalogState
SupersededLegacy *SupersededLegacyState
StateErr error
HasContents bool
}
type SupersededLegacyState struct {
SchemaVersion int
}
type StatePolicy struct {
Mode string
}
type OwnerScope struct {
PipelineID string
DestinationID string
}
type rawStatePolicy struct {
Mode string `json:"mode"`
}
func ParseDocument(data []byte) (StateDocument, error) {
schemaVersion, err := parseSchemaVersion(data)
if err != nil {
return StateDocument{}, err
}
switch schemaVersion {
case legacySchemaVersion, legacyOwnerSchema, legacyMultiOwnerSchema:
return StateDocument{SupersededLegacy: &SupersededLegacyState{SchemaVersion: schemaVersion}}, nil
case CatalogSchemaVersion:
catalog, err := ParseCatalog(data)
if err != nil {
return StateDocument{}, err
}
return StateDocument{Catalog: &catalog}, nil
default:
return StateDocument{}, fmt.Errorf("state schema_version %d is unsupported", schemaVersion)
}
}
func parseSchemaVersion(data []byte) (int, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
var raw struct {
SchemaVersion *int `json:"schema_version"`
}
if err := decoder.Decode(&raw); err != nil {
return 0, fmt.Errorf("parse distributor state: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return 0, fmt.Errorf("parse distributor state: trailing data")
}
if raw.SchemaVersion == nil {
return 0, fmt.Errorf("state schema_version is required")
}
return *raw.SchemaVersion, nil
}
func (p StatePolicy) MarshalJSON() ([]byte, error) {
type policyJSON struct {
Mode string `json:"mode"`
}
return json.Marshal(policyJSON{Mode: p.Mode})
}
func CurrentOwnerScope(pipelineID, destinationID string) OwnerScope {
return OwnerScope{PipelineID: pipelineID, DestinationID: destinationID}
}
func parseRequiredTime(field string, raw *string) (time.Time, error) {
if raw == nil || *raw == "" {
return time.Time{}, fmt.Errorf("%s is required", field)
}
parsed, err := time.Parse(time.RFC3339, *raw)
if err != nil {
return time.Time{}, fmt.Errorf("%s must be RFC3339: %w", field, err)
}
return parsed.UTC(), nil
}

74
internal/state/outputs.go Normal file
View File

@@ -0,0 +1,74 @@
package state
func FindCatalogOutputByPath(outputs []CatalogOutputFile, path string) (CatalogOutputFile, bool) {
for _, output := range outputs {
if output.Path == path {
return output, true
}
}
return CatalogOutputFile{}, false
}
func CatalogOutputsForOwner(outputs []CatalogOutputFile, scope OwnerScope) []CatalogOutputFile {
selected := make([]CatalogOutputFile, 0, len(outputs))
for _, output := range outputs {
if output.PipelineID == scope.PipelineID && output.DestinationID == scope.DestinationID {
selected = append(selected, output)
}
}
return selected
}
func CatalogManagedOutputPaths(s CatalogState) []string {
paths := make([]string, 0, len(s.Outputs))
for _, output := range s.Outputs {
paths = append(paths, output.Path)
}
return paths
}
func RemoveMissingCatalogOwnerOutputs(s CatalogState, scope OwnerScope, missingPaths []string) (CatalogState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]CatalogOutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if output.PipelineID == scope.PipelineID && output.DestinationID == scope.DestinationID {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func RemoveMissingCatalogOutputs(s CatalogState, missingPaths []string) (CatalogState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]CatalogOutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func pathSet(paths []string) map[string]struct{} {
set := make(map[string]struct{}, len(paths))
for _, path := range paths {
set[path] = struct{}{}
}
return set
}

110
internal/state/prune.go Normal file
View File

@@ -0,0 +1,110 @@
package state
import (
"sort"
"time"
)
type PruneCandidate struct {
Path string
UpdatedAt time.Time
Owner *OwnerScope
}
type PrunePlanOptions struct {
Now time.Time
OlderThan *time.Duration
KeepLatest *int
}
type PrunePlan struct {
Pruned []PruneCandidate
Preserved []PruneCandidate
}
func CatalogPruneCandidates(s CatalogState, scope OwnerScope) []PruneCandidate {
candidates := make([]PruneCandidate, 0, len(s.Outputs))
for _, output := range s.Outputs {
if output.PipelineID != scope.PipelineID || output.DestinationID != scope.DestinationID {
continue
}
owner := scope
candidates = append(candidates, PruneCandidate{
Path: output.Path,
UpdatedAt: output.UpdatedAt,
Owner: &owner,
})
}
return candidates
}
func PlanPrune(candidates []PruneCandidate, options PrunePlanOptions) PrunePlan {
ordered := append([]PruneCandidate(nil), candidates...)
sortPruneCandidatesNewestFirst(ordered)
if options.OlderThan == nil && options.KeepLatest == nil {
return PrunePlan{
Pruned: []PruneCandidate{},
Preserved: ordered,
}
}
preservedByPath := make(map[string]struct{})
if options.KeepLatest != nil {
keep := *options.KeepLatest
if keep < 0 {
keep = 0
}
if keep > len(ordered) {
keep = len(ordered)
}
for _, candidate := range ordered[:keep] {
preservedByPath[candidate.Path] = struct{}{}
}
}
plan := PrunePlan{
Pruned: []PruneCandidate{},
Preserved: []PruneCandidate{},
}
cutoff := time.Time{}
if options.OlderThan != nil {
now := options.Now.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
cutoff = now.Add(-*options.OlderThan)
}
for _, candidate := range ordered {
if _, preserved := preservedByPath[candidate.Path]; preserved {
plan.Preserved = append(plan.Preserved, candidate)
continue
}
if options.OlderThan == nil || candidate.UpdatedAt.Before(cutoff) {
plan.Pruned = append(plan.Pruned, candidate)
continue
}
plan.Preserved = append(plan.Preserved, candidate)
}
sortPruneCandidatesOldestFirst(plan.Pruned)
sortPruneCandidatesNewestFirst(plan.Preserved)
return plan
}
func sortPruneCandidatesNewestFirst(candidates []PruneCandidate) {
sort.Slice(candidates, func(i, j int) bool {
if !candidates[i].UpdatedAt.Equal(candidates[j].UpdatedAt) {
return candidates[i].UpdatedAt.After(candidates[j].UpdatedAt)
}
return candidates[i].Path < candidates[j].Path
})
}
func sortPruneCandidatesOldestFirst(candidates []PruneCandidate) {
sort.Slice(candidates, func(i, j int) bool {
if !candidates[i].UpdatedAt.Equal(candidates[j].UpdatedAt) {
return candidates[i].UpdatedAt.Before(candidates[j].UpdatedAt)
}
return candidates[i].Path < candidates[j].Path
})
}

View File

@@ -0,0 +1,83 @@
package state
import (
"strings"
"testing"
"time"
)
func TestPlanPruneOlderThan(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
olderThan := 48 * time.Hour
plan := PlanPrune([]PruneCandidate{
{Path: "old.txt", UpdatedAt: now.Add(-72 * time.Hour)},
{Path: "fresh.txt", UpdatedAt: now.Add(-24 * time.Hour)},
}, PrunePlanOptions{Now: now, OlderThan: &olderThan})
if got, want := pruneCandidatePaths(plan.Pruned), "old.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Preserved), "fresh.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
}
func TestPlanPruneKeepLatest(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
keepLatest := 2
plan := PlanPrune([]PruneCandidate{
{Path: "old.txt", UpdatedAt: now.Add(-72 * time.Hour)},
{Path: "new.txt", UpdatedAt: now.Add(-1 * time.Hour)},
{Path: "middle.txt", UpdatedAt: now.Add(-24 * time.Hour)},
}, PrunePlanOptions{KeepLatest: &keepLatest})
if got, want := pruneCandidatePaths(plan.Pruned), "old.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Preserved), "new.txt,middle.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
}
func TestPlanPruneCombinedPolicyPreservesLatestBeforeAgeCheck(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
olderThan := 48 * time.Hour
keepLatest := 1
plan := PlanPrune([]PruneCandidate{
{Path: "oldest.txt", UpdatedAt: now.Add(-96 * time.Hour)},
{Path: "old.txt", UpdatedAt: now.Add(-72 * time.Hour)},
{Path: "fresh.txt", UpdatedAt: now.Add(-24 * time.Hour)},
}, PrunePlanOptions{Now: now, OlderThan: &olderThan, KeepLatest: &keepLatest})
if got, want := pruneCandidatePaths(plan.Pruned), "oldest.txt,old.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Preserved), "fresh.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
}
func TestPlanPruneDeterministicTieBreaking(t *testing.T) {
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
keepLatest := 1
plan := PlanPrune([]PruneCandidate{
{Path: "b.txt", UpdatedAt: now},
{Path: "a.txt", UpdatedAt: now},
{Path: "c.txt", UpdatedAt: now.Add(-time.Hour)},
}, PrunePlanOptions{KeepLatest: &keepLatest})
if got, want := pruneCandidatePaths(plan.Preserved), "a.txt"; got != want {
t.Fatalf("preserved = %q, want %q", got, want)
}
if got, want := pruneCandidatePaths(plan.Pruned), "c.txt,b.txt"; got != want {
t.Fatalf("pruned = %q, want %q", got, want)
}
}
func pruneCandidatePaths(candidates []PruneCandidate) string {
paths := make([]string, 0, len(candidates))
for _, candidate := range candidates {
paths = append(paths, candidate.Path)
}
return strings.Join(paths, ",")
}

View File

@@ -1,84 +0,0 @@
package state
import (
"fmt"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/link"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
const (
OutputKindSource = "source"
OutputKindGenerated = "generated"
)
func Validate(s DistributorState) error {
if s.SchemaVersion != SchemaVersion {
return fmt.Errorf("state schema_version must be %d", SchemaVersion)
}
if s.PipelineID == "" {
return fmt.Errorf("state pipeline_id is required")
}
if s.DestinationID == "" {
return fmt.Errorf("state destination_id is required")
}
if s.PublishedAt.IsZero() {
return fmt.Errorf("state published_at is required")
}
if err := validateEmbeddedManifest(s.Source.Manifest); err != nil {
return fmt.Errorf("state source.manifest: %w", err)
}
if s.Links != nil && s.Links.PrimaryURL != "" {
if err := link.ValidateHTTPURL(s.Links.PrimaryURL); err != nil {
return fmt.Errorf("state links.primary_url: %w", err)
}
}
if s.Outputs == nil {
return fmt.Errorf("state outputs is required")
}
seen := make(map[string]struct{}, len(s.Outputs))
for index, output := range s.Outputs {
if err := validateOutput(index, output); err != nil {
return err
}
if _, exists := seen[output.Path]; exists {
return fmt.Errorf("state outputs[%d].path duplicates %q", index, output.Path)
}
seen[output.Path] = struct{}{}
}
return nil
}
func validateEmbeddedManifest(manifest bundle.Manifest) error {
return bundle.ValidateManifest(manifest)
}
func validateOutput(index int, output OutputFile) error {
if err := storage.ValidatePath(output.Path); err != nil {
return fmt.Errorf("state outputs[%d].path: %w", index, err)
}
switch output.Kind {
case OutputKindSource, OutputKindGenerated:
default:
return fmt.Errorf("state outputs[%d].kind must be source or generated", index)
}
if err := storage.ValidatePath(output.SourcePath); err != nil {
return fmt.Errorf("state outputs[%d].source_path: %w", index, err)
}
if output.Kind == OutputKindGenerated && output.Transform == "" {
return fmt.Errorf("state outputs[%d].transform is required for generated output", index)
}
if output.URL != "" {
if err := link.ValidateHTTPURL(output.URL); err != nil {
return fmt.Errorf("state outputs[%d].url: %w", index, err)
}
}
if err := bundle.ValidateDigest(output.SHA256); err != nil {
return fmt.Errorf("state outputs[%d].sha256: %w", index, err)
}
if output.Size < 0 {
return fmt.Errorf("state outputs[%d].size must be non-negative", index)
}
return nil
}

View File

@@ -29,6 +29,7 @@ type Backend interface {
Stat(ctx context.Context, path string) (Entry, error) Stat(ctx context.Context, path string) (Entry, error)
Walk(ctx context.Context, prefix string, opts WalkOptions, fn WalkFunc) error Walk(ctx context.Context, prefix string, opts WalkOptions, fn WalkFunc) error
HasAny(ctx context.Context, prefix string) (bool, error) HasAny(ctx context.Context, prefix string) (bool, error)
DeleteManagedOutputs(ctx context.Context, bundlePath string, managedOutputPaths []string, opts DeleteOptions) error
DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts DeleteOptions) error DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts DeleteOptions) error
DeletePrefix(ctx context.Context, prefix string, opts DeleteOptions) error DeletePrefix(ctx context.Context, prefix string, opts DeleteOptions) error
} }

View File

@@ -20,18 +20,19 @@ const (
) )
const ( const (
OpValidatePath = "validate path" OpValidatePath = "validate path"
OpReadFile = "read file" OpReadFile = "read file"
OpOpenReader = "open reader" OpOpenReader = "open reader"
OpWriteFile = "write file" OpWriteFile = "write file"
OpWriteFrom = "write stream" OpWriteFrom = "write stream"
OpStat = "stat" OpStat = "stat"
OpWalk = "walk" OpWalk = "walk"
OpHasAny = "has any" OpHasAny = "has any"
OpDeleteManagedBundle = "delete managed bundle" OpDeleteManagedOutputs = "delete managed outputs"
OpDeletePrefix = "delete prefix" OpDeleteManagedBundle = "delete managed bundle"
OpRegisterBackend = "register backend" OpDeletePrefix = "delete prefix"
OpOpenBackend = "open backend" OpRegisterBackend = "register backend"
OpOpenBackend = "open backend"
) )
type Error struct { type Error struct {

View File

@@ -159,23 +159,35 @@ 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 { 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 { if err := ctx.Err(); err != nil {
return err return err
} }
targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) targets, err := targetsFunc()
if err != nil { if err != nil {
return err return err
} }
for _, target := range targets { for _, target := range targets {
if _, ok := b.dirs[target]; ok { if _, ok := b.dirs[target]; ok {
return storage.NewError(storage.OpDeleteManagedBundle, backendName, target, storage.ErrUnsupported, nil) return storage.NewError(op, backendName, target, storage.ErrUnsupported, nil)
} }
if !b.exists(target) { if !b.exists(target) {
if opts.IgnoreMissing { if opts.IgnoreMissing {
continue continue
} }
return storage.NewError(storage.OpDeleteManagedBundle, backendName, target, storage.ErrNotFound, nil) return storage.NewError(op, backendName, target, storage.ErrNotFound, nil)
} }
delete(b.files, target) delete(b.files, target)
delete(b.symlinks, target) delete(b.symlinks, target)

View File

@@ -50,10 +50,23 @@ func DisplayPath(path string) string {
} }
func ManagedBundleTargets(bundlePath string, managedOutputPaths []string) ([]string, error) { func ManagedBundleTargets(bundlePath string, managedOutputPaths []string) ([]string, error) {
targets, err := ManagedOutputTargets(bundlePath, managedOutputPaths)
if err != nil {
return nil, err
}
statePath, err := StatePath(bundlePath)
if err != nil {
return nil, err
}
targets = append(targets, statePath)
return targets, nil
}
func ManagedOutputTargets(bundlePath string, managedOutputPaths []string) ([]string, error) {
if err := ValidatePrefix(bundlePath); err != nil { if err := ValidatePrefix(bundlePath); err != nil {
return nil, err return nil, err
} }
targets := make([]string, 0, len(managedOutputPaths)+1) targets := make([]string, 0, len(managedOutputPaths))
for _, outputPath := range managedOutputPaths { for _, outputPath := range managedOutputPaths {
target, err := Join(bundlePath, outputPath) target, err := Join(bundlePath, outputPath)
if err != nil { if err != nil {
@@ -61,11 +74,6 @@ func ManagedBundleTargets(bundlePath string, managedOutputPaths []string) ([]str
} }
targets = append(targets, target) targets = append(targets, target)
} }
statePath, err := StatePath(bundlePath)
if err != nil {
return nil, err
}
targets = append(targets, statePath)
return targets, nil return targets, nil
} }

View File

@@ -132,6 +132,22 @@ func TestManagedBundleTargetsRejectsInvalidOutputPath(t *testing.T) {
} }
} }
func TestManagedOutputTargetsOmitsStateFile(t *testing.T) {
targets, err := ManagedOutputTargets("bundle", []string{"report.md", "nested/report.html"})
if err != nil {
t.Fatalf("ManagedOutputTargets() error = %v", err)
}
want := []string{"bundle/report.md", "bundle/nested/report.html"}
if len(targets) != len(want) {
t.Fatalf("targets = %v, want %v", targets, want)
}
for index := range want {
if targets[index] != want[index] {
t.Fatalf("targets = %v, want %v", targets, want)
}
}
}
func TestListSortsEntries(t *testing.T) { func TestListSortsEntries(t *testing.T) {
backend := walkBackend{ backend := walkBackend{
entries: []Entry{ entries: []Entry{
@@ -215,6 +231,10 @@ func (b walkBackend) DeleteManagedBundle(context.Context, string, []string, Dele
return nil return nil
} }
func (b walkBackend) DeleteManagedOutputs(context.Context, string, []string, DeleteOptions) error {
return nil
}
func (b walkBackend) DeletePrefix(context.Context, string, DeleteOptions) error { func (b walkBackend) DeletePrefix(context.Context, string, DeleteOptions) error {
return nil return nil
} }

View File

@@ -11,7 +11,6 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle" "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/storage"
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake" "gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
) )
@@ -32,13 +31,6 @@ type BundleOptions struct {
ExtraFiles []SourceFile ExtraFiles []SourceFile
} }
type DestinationStateOptions struct {
PipelineID string
DestinationID string
DistributorVersion string
PublishedAt time.Time
}
func DefaultSourceFiles() []SourceFile { func DefaultSourceFiles() []SourceFile {
return []SourceFile{ return []SourceFile{
{Path: "report.md", Data: "# Report\nSunny.\n"}, {Path: "report.md", Data: "# Report\nSunny.\n"},
@@ -132,28 +124,6 @@ func AssertFakeMissing(t testing.TB, backend *fake.Backend, path string) {
} }
} }
func WriteFakeDestinationState(t testing.TB, backend *fake.Backend, relative string, manifest bundle.Manifest, opts DestinationStateOptions) state.DistributorState {
t.Helper()
destinationState := DestinationState(manifest, opts)
data, err := json.MarshalIndent(destinationState, "", " ")
if err != nil {
t.Fatalf("marshal destination state: %v", err)
}
statePath, err := storage.StatePath(relative)
if err != nil {
t.Fatalf("state path: %v", err)
}
WriteFakeFile(t, backend, statePath, string(append(data, '\n')))
for _, output := range destinationState.Outputs {
path, err := storage.Join(relative, output.Path)
if err != nil {
t.Fatalf("join output path: %v", err)
}
WriteFakeFile(t, backend, path, "old")
}
return destinationState
}
func WriteMinimalLocalConfig(t testing.TB, sourceRoot, destinationRoot string) string { func WriteMinimalLocalConfig(t testing.TB, sourceRoot, destinationRoot string) string {
t.Helper() t.Helper()
return writeConfigFile(t, ` return writeConfigFile(t, `
@@ -317,42 +287,6 @@ pipelines:
`) `)
} }
func WriteDestinationState(t testing.TB, root, relative string, manifest bundle.Manifest, opts DestinationStateOptions) state.DistributorState {
t.Helper()
bundleRoot := filepath.Join(root, filepath.FromSlash(relative))
if err := os.MkdirAll(bundleRoot, 0o755); err != nil {
t.Fatalf("mkdir destination bundle: %v", err)
}
destinationState := DestinationState(manifest, opts)
writeJSONFile(t, filepath.Join(bundleRoot, storage.StateFileName), destinationState)
return destinationState
}
func DestinationState(manifest bundle.Manifest, opts DestinationStateOptions) state.DistributorState {
return state.DistributorState{
SchemaVersion: state.SchemaVersion,
DistributorVersion: opts.DistributorVersion,
PipelineID: defaultString(opts.PipelineID, "reports"),
DestinationID: defaultString(opts.DestinationID, "archive"),
PublishedAt: defaultPublishedAt(opts.PublishedAt),
Source: state.SourceState{Manifest: manifest},
Outputs: sourceOutputs(manifest),
}
}
func ReadDestinationState(t testing.TB, path string) state.DistributorState {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read destination state: %v", err)
}
destinationState, err := state.Parse(data)
if err != nil {
t.Fatalf("parse destination state: %v", err)
}
return destinationState
}
func AssertFile(t testing.TB, path, want string) { func AssertFile(t testing.TB, path, want string) {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -386,20 +320,6 @@ func sourceFiles(opts BundleOptions) []SourceFile {
return files return files
} }
func sourceOutputs(manifest bundle.Manifest) []state.OutputFile {
outputs := make([]state.OutputFile, 0, len(manifest.Files))
for _, file := range manifest.Files {
outputs = append(outputs, state.OutputFile{
Path: file.Path,
Kind: state.OutputKindSource,
SourcePath: file.Path,
SHA256: file.SHA256,
Size: file.Size,
})
}
return outputs
}
func writeConfigFile(t testing.TB, body string) string { func writeConfigFile(t testing.TB, body string) string {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
@@ -447,10 +367,3 @@ func defaultCreated(value time.Time) time.Time {
} }
return value return value
} }
func defaultPublishedAt(value time.Time) time.Time {
if value.IsZero() {
return time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
}
return value
}

View File

@@ -123,7 +123,7 @@ func (t *Transformer) render(ctx context.Context, req transform.Request, sourceF
if err := t.renderer.Convert(data, &rendered); err != nil { if err := t.renderer.Convert(data, &rendered); err != nil {
return nil, fmt.Errorf("render markdown source %q: %w", sourceFile, err) return nil, fmt.Errorf("render markdown source %q: %w", sourceFile, err)
} }
return wrapHTML(rendered.Bytes()), nil return wrapHTML(rendered.Bytes(), req.Markdown.CssHref), nil
} }
func markdownMode(mode string) string { func markdownMode(mode string) string {

View File

@@ -40,6 +40,43 @@ func TestGenerateMarkdownSidecar(t *testing.T) {
} }
} }
func TestGenerateMarkdownWithoutCSSHrefPreservesWrapper(t *testing.T) {
backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n")
outputs, err := New().Generate(context.Background(), transform.Request{SourceBackend: backend, SourceBundle: sourceBundle})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
want := "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title></title>\n</head>\n<body>\n<h1>Title</h1>\n<p>Hello.</p>\n</body>\n</html>\n"
if got := string(outputs[0].Data); got != want {
t.Fatalf("html = %q, want existing wrapper %q", got, want)
}
}
func TestGenerateMarkdownSidecarWithCSSHref(t *testing.T) {
backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n")
outputs, err := New().Generate(context.Background(), transform.Request{
SourceBackend: backend,
SourceBundle: sourceBundle,
Markdown: transform.MarkdownOptions{CssHref: "/assets/report.css?v=1&theme=main"},
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
output := outputs[0]
html := string(output.Data)
wantLink := "<meta charset=\"utf-8\">\n<link rel=\"stylesheet\" href=\"/assets/report.css?v=1&amp;theme=main\">\n<title></title>"
if !strings.Contains(html, wantLink) {
t.Fatalf("html = %q, want stylesheet link %q", html, wantLink)
}
if output.SHA256 != bundle.FileDigest(output.Data) || output.Size != int64(len(output.Data)) {
t.Fatalf("digest/size metadata = %s/%d", output.SHA256, output.Size)
}
}
func TestGenerateMarkdownIndexExplicitInput(t *testing.T) { func TestGenerateMarkdownIndexExplicitInput(t *testing.T) {
backend := fake.New() backend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, backend, "", testutil.BundleOptions{ sourceBundle := testutil.WriteFakeSourceBundle(t, backend, "", testutil.BundleOptions{
@@ -74,6 +111,29 @@ func TestGenerateMarkdownIndexExplicitInput(t *testing.T) {
} }
} }
func TestGenerateMarkdownIndexWithCSSHref(t *testing.T) {
backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n")
outputs, err := New().Generate(context.Background(), transform.Request{
SourceBackend: backend,
SourceBundle: sourceBundle,
Markdown: transform.MarkdownOptions{
Mode: transform.MarkdownModeIndex,
CssHref: "https://example.com/assets/report.css",
},
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if got, want := outputs[0].Path, "index.html"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if !strings.Contains(string(outputs[0].Data), `<link rel="stylesheet" href="https://example.com/assets/report.css">`) {
t.Fatalf("html = %q, want stylesheet link", outputs[0].Data)
}
}
func TestGenerateMarkdownIndexSelectsOnlyMarkdownFile(t *testing.T) { func TestGenerateMarkdownIndexSelectsOnlyMarkdownFile(t *testing.T) {
backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n") backend, sourceBundle := markdownFixture(t, "# Title\n\nHello.\n")

View File

@@ -1,10 +1,19 @@
package markdown package markdown
import "bytes" import (
"bytes"
"html"
)
func wrapHTML(body []byte) []byte { func wrapHTML(body []byte, cssHref string) []byte {
var buf bytes.Buffer var buf bytes.Buffer
buf.WriteString("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title></title>\n</head>\n<body>\n") buf.WriteString("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n")
if cssHref != "" {
buf.WriteString("<link rel=\"stylesheet\" href=\"")
buf.WriteString(html.EscapeString(cssHref))
buf.WriteString("\">\n")
}
buf.WriteString("<title></title>\n</head>\n<body>\n")
buf.Write(body) buf.Write(body)
buf.WriteString("</body>\n</html>\n") buf.WriteString("</body>\n</html>\n")
return buf.Bytes() return buf.Bytes()

View File

@@ -23,8 +23,9 @@ type Request struct {
} }
type MarkdownOptions struct { type MarkdownOptions struct {
Mode string Mode string
Input string Input string
CssHref string
} }
type Transformer interface { type Transformer interface {

View File

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

View File

@@ -15,6 +15,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@@ -22,7 +23,6 @@ import (
) )
const ( const (
uploadPath = "upload"
runsPath = "runs" runsPath = "runs"
idempotencyKeyHeader = "Idempotency-Key" idempotencyKeyHeader = "Idempotency-Key"
defaultHTTPTimeout = 30 * time.Second defaultHTTPTimeout = 30 * time.Second
@@ -34,6 +34,8 @@ const (
redactedSecret = "[redacted]" redactedSecret = "[redacted]"
) )
var pipelineIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
func NewClient(opts ClientOptions) (*Client, error) { func NewClient(opts ClientOptions) (*Client, error) {
endpoint, err := cleanEndpoint(opts.Endpoint) endpoint, err := cleanEndpoint(opts.Endpoint)
if err != nil { if err != nil {
@@ -65,6 +67,9 @@ func (c *Client) UploadBundle(ctx context.Context, opts UploadBundleOptions) (Re
if opts.Validate && opts.DisableValidation { if opts.Validate && opts.DisableValidation {
return Result{}, fmt.Errorf("validate and disable validation cannot both be set") return Result{}, fmt.Errorf("validate and disable validation cannot both be set")
} }
if err := validatePipelineID(opts.PipelineID); err != nil {
return Result{}, err
}
if opts.Root == "" { if opts.Root == "" {
return Result{}, fmt.Errorf("root is required") return Result{}, fmt.Errorf("root is required")
} }
@@ -85,7 +90,7 @@ func (c *Client) UploadBundle(ctx context.Context, opts UploadBundleOptions) (Re
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
return c.uploadArchive(ctx, archive, key) return c.uploadArchive(ctx, opts.PipelineID, archive, key)
} }
func (c *Client) UploadFiles(ctx context.Context, opts UploadFilesOptions) (Result, error) { func (c *Client) UploadFiles(ctx context.Context, opts UploadFilesOptions) (Result, error) {
@@ -95,6 +100,9 @@ func (c *Client) UploadFiles(ctx context.Context, opts UploadFilesOptions) (Resu
if opts.Validate && opts.DisableValidation { if opts.Validate && opts.DisableValidation {
return Result{}, fmt.Errorf("validate and disable validation cannot both be set") return Result{}, fmt.Errorf("validate and disable validation cannot both be set")
} }
if err := validatePipelineID(opts.PipelineID); err != nil {
return Result{}, err
}
if opts.ID == "" { if opts.ID == "" {
return Result{}, fmt.Errorf("id is required") return Result{}, fmt.Errorf("id is required")
} }
@@ -131,7 +139,7 @@ func (c *Client) UploadFiles(ctx context.Context, opts UploadFilesOptions) (Resu
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
return c.uploadArchive(ctx, archive, key) return c.uploadArchive(ctx, opts.PipelineID, archive, key)
} }
func (c *Client) Status(ctx context.Context, runID string) (RunStatus, error) { func (c *Client) Status(ctx context.Context, runID string) (RunStatus, error) {
@@ -167,7 +175,7 @@ func (c *Client) Status(ctx context.Context, runID string) (RunStatus, error) {
return status, nil return status, nil
} }
func (c *Client) uploadArchive(ctx context.Context, archive []byte, idempotencyKey string) (Result, error) { func (c *Client) uploadArchive(ctx context.Context, pipelineID string, archive []byte, idempotencyKey string) (Result, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@@ -176,7 +184,7 @@ func (c *Client) uploadArchive(ctx context.Context, archive []byte, idempotencyK
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return Result{}, err return Result{}, err
} }
result, retry, err := c.uploadAttempt(ctx, archive, idempotencyKey) result, retry, err := c.uploadAttempt(ctx, pipelineID, archive, idempotencyKey)
if err == nil { if err == nil {
return result, nil return result, nil
} }
@@ -191,8 +199,8 @@ func (c *Client) uploadArchive(ctx context.Context, archive []byte, idempotencyK
return Result{}, lastErr return Result{}, lastErr
} }
func (c *Client) uploadAttempt(ctx context.Context, archive []byte, idempotencyKey string) (Result, bool, error) { func (c *Client) uploadAttempt(ctx context.Context, pipelineID string, archive []byte, idempotencyKey string) (Result, bool, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.uploadURL(), bytes.NewReader(archive)) request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.uploadURL(pipelineID), bytes.NewReader(archive))
if err != nil { if err != nil {
return Result{}, false, c.redactError(err) return Result{}, false, c.redactError(err)
} }
@@ -227,8 +235,8 @@ func (c *Client) authorize(request *http.Request) {
request.Header.Set("Authorization", authorizationPrefix+c.token) request.Header.Set("Authorization", authorizationPrefix+c.token)
} }
func (c *Client) uploadURL() string { func (c *Client) uploadURL(pipelineID string) string {
return joinEndpointPath(c.endpoint, uploadPath) return joinEndpointPath(c.endpoint, "v1", "pipelines", pipelineID, "upload")
} }
func (c *Client) statusURL(runID string) string { func (c *Client) statusURL(runID string) string {
@@ -343,6 +351,16 @@ func uploadIdempotencyKey(value string) (string, error) {
return value, nil return value, nil
} }
func validatePipelineID(value string) error {
if value == "" {
return fmt.Errorf("pipeline id is required")
}
if !pipelineIDPattern.MatchString(value) {
return fmt.Errorf("pipeline id must be a slug-like identifier")
}
return nil
}
func validateIdempotencyKey(value string) error { func validateIdempotencyKey(value string) error {
if value == "" { if value == "" {
return fmt.Errorf("idempotency key is required") return fmt.Errorf("idempotency key is required")

View File

@@ -47,7 +47,7 @@ func TestNewClientValidatesOptions(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
if got, want := client.uploadURL(), "http://127.0.0.1:8080/base/upload"; got != want { if got, want := client.uploadURL("reports.daily"), "http://127.0.0.1:8080/base/v1/pipelines/reports.daily/upload"; got != want {
t.Fatalf("upload URL = %q, want %q", got, want) t.Fatalf("upload URL = %q, want %q", got, want)
} }
if client.httpClient == nil || client.httpClient.Timeout == 0 { if client.httpClient == nil || client.httpClient.Timeout == 0 {
@@ -65,7 +65,7 @@ func TestUploadBundleSendsCallerKeyAndManifestArchive(t *testing.T) {
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/upload"; got != want { if got, want := r.URL.Path, "/v1/pipelines/reports.daily/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want) t.Fatalf("path = %q, want %q", got, want)
} }
if got, want := r.Header.Get("Authorization"), "Bearer secret-token"; got != want { if got, want := r.Header.Get("Authorization"), "Bearer secret-token"; got != want {
@@ -93,6 +93,7 @@ func TestUploadBundleSendsCallerKeyAndManifestArchive(t *testing.T) {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
result, err := client.UploadBundle(context.Background(), UploadBundleOptions{ result, err := client.UploadBundle(context.Background(), UploadBundleOptions{
PipelineID: "reports.daily",
Root: root, Root: root,
IdempotencyKey: "producer.retry:one", IdempotencyKey: "producer.retry:one",
}) })
@@ -112,6 +113,9 @@ func TestUploadFilesBuildsTemporaryBundleWithoutTouchingSources(t *testing.T) {
} }
tempDir := t.TempDir() tempDir := t.TempDir()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/v1/pipelines/reports.files/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
entries := readArchiveEntries(t, r.Body) entries := readArchiveEntries(t, r.Body)
if got := string(entries["manifest.json"]); !strings.Contains(got, `"id": "reports.from.files"`) { if got := string(entries["manifest.json"]); !strings.Contains(got, `"id": "reports.from.files"`) {
t.Fatalf("manifest = %s, want uploaded id", got) t.Fatalf("manifest = %s, want uploaded id", got)
@@ -131,7 +135,8 @@ func TestUploadFilesBuildsTemporaryBundleWithoutTouchingSources(t *testing.T) {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
_, err = client.UploadFiles(context.Background(), UploadFilesOptions{ _, err = client.UploadFiles(context.Background(), UploadFilesOptions{
ID: "reports.from.files", PipelineID: "reports.files",
ID: "reports.from.files",
Files: []sourcebundle.BundleFile{{ Files: []sourcebundle.BundleFile{{
SourcePath: sourcePath, SourcePath: sourcePath,
Path: "reports/report.md", Path: "reports/report.md",
@@ -153,6 +158,77 @@ func TestUploadFilesBuildsTemporaryBundleWithoutTouchingSources(t *testing.T) {
} }
} }
func TestUploadMethodsRequirePipelineIDBeforeLocalWork(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
t.Fatal("server should not receive request")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
missingRoot := filepath.Join(t.TempDir(), "missing")
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: missingRoot}); err == nil || !strings.Contains(err.Error(), "pipeline id is required") {
t.Fatalf("UploadBundle() error = %v, want missing pipeline id", err)
}
tempDir := t.TempDir()
sourcePath := filepath.Join(t.TempDir(), "report.md")
if err := os.WriteFile(sourcePath, []byte("data"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
if _, err := client.UploadFiles(context.Background(), UploadFilesOptions{
ID: "reports.from.files",
Files: []sourcebundle.BundleFile{{
SourcePath: sourcePath,
Path: "report.md",
}},
TempDir: tempDir,
}); err == nil || !strings.Contains(err.Error(), "pipeline id is required") {
t.Fatalf("UploadFiles() error = %v, want missing pipeline id", err)
}
entries, err := os.ReadDir(tempDir)
if err != nil {
t.Fatalf("read temp dir: %v", err)
}
if len(entries) != 0 {
t.Fatalf("temp dir entries = %d, want no local bundle work", len(entries))
}
if got := requests.Load(); got != 0 {
t.Fatalf("requests = %d, want 0", got)
}
}
func TestUploadMethodsRejectInvalidPipelineIDBeforeHTTPRequest(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "data"}})
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
t.Fatal("server should not receive request")
}))
defer server.Close()
client, err := NewClient(ClientOptions{Endpoint: server.URL, Token: "secret", HTTPClient: server.Client()})
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
for _, pipelineID := range []string{".reports", "reports/daily", "reports daily"} {
t.Run(pipelineID, func(t *testing.T) {
_, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: pipelineID, Root: root})
if err == nil || !strings.Contains(err.Error(), "pipeline id must be a slug-like identifier") {
t.Fatalf("UploadBundle() error = %v, want invalid pipeline id", err)
}
})
}
if got := requests.Load(); got != 0 {
t.Fatalf("requests = %d, want 0", got)
}
}
func TestUploadBundleValidationFailurePreventsHTTPRequest(t *testing.T) { func TestUploadBundleValidationFailurePreventsHTTPRequest(t *testing.T) {
root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "original"}}) root := writeTestBundle(t, "reports.daily", []testFile{{path: "report.md", data: "original"}})
if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("changed"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(root, "report.md"), []byte("changed"), 0o600); err != nil {
@@ -169,7 +245,7 @@ func TestUploadBundleValidationFailurePreventsHTTPRequest(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root}); err == nil { if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root}); err == nil {
t.Fatal("UploadBundle() error = nil, want validation error") t.Fatal("UploadBundle() error = nil, want validation error")
} }
if got := requests.Load(); got != 0 { if got := requests.Load(); got != 0 {
@@ -193,7 +269,7 @@ func TestUploadBundleCanDisableLocalValidation(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, DisableValidation: true}); err != nil { if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, DisableValidation: true}); err != nil {
t.Fatalf("UploadBundle() error = %v", err) t.Fatalf("UploadBundle() error = %v", err)
} }
if got := requests.Load(); got != 1 { if got := requests.Load(); got != 1 {
@@ -206,6 +282,9 @@ func TestGeneratedIdempotencyKeyIsReusedAcrossRetry(t *testing.T) {
var attempts atomic.Int64 var attempts atomic.Int64
var keys []string var keys []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/v1/pipelines/reports/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
keys = append(keys, r.Header.Get(idempotencyKeyHeader)) keys = append(keys, r.Header.Get(idempotencyKeyHeader))
if attempts.Add(1) == 1 { if attempts.Add(1) == 1 {
writeJSONError(w, http.StatusServiceUnavailable, "busy", false) writeJSONError(w, http.StatusServiceUnavailable, "busy", false)
@@ -224,7 +303,7 @@ func TestGeneratedIdempotencyKeyIsReusedAcrossRetry(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root}); err != nil { if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root}); err != nil {
t.Fatalf("UploadBundle() error = %v", err) t.Fatalf("UploadBundle() error = %v", err)
} }
if got, want := attempts.Load(), int64(2); got != want { if got, want := attempts.Load(), int64(2); got != want {
@@ -276,7 +355,7 @@ func TestUploadResponseParsingAndNoRetryStatuses(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
_, err = client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "key"}) _, err = client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "key"})
if err == nil { if err == nil {
t.Fatal("UploadBundle() error = nil, want error") t.Fatal("UploadBundle() error = nil, want error")
} }
@@ -309,7 +388,7 @@ func TestTokenRedactedFromHTTPError(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
_, err = client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "key"}) _, err = client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "key"})
if err == nil { if err == nil {
t.Fatal("UploadBundle() error = nil, want error") t.Fatal("UploadBundle() error = nil, want error")
} }
@@ -330,6 +409,9 @@ func TestNetworkRetryUsesSameIdempotencyKey(t *testing.T) {
Token: "secret", Token: "secret",
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
keys = append(keys, request.Header.Get(idempotencyKeyHeader)) keys = append(keys, request.Header.Get(idempotencyKeyHeader))
if got, want := request.URL.Path, "/v1/pipelines/reports/upload"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if attempts.Add(1) == 1 { if attempts.Add(1) == 1 {
return nil, temporaryNetworkError{} return nil, temporaryNetworkError{}
} }
@@ -346,7 +428,7 @@ func TestNetworkRetryUsesSameIdempotencyKey(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
result, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "network-retry"}) result, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "network-retry"})
if err != nil { if err != nil {
t.Fatalf("UploadBundle() error = %v", err) t.Fatalf("UploadBundle() error = %v", err)
} }
@@ -381,7 +463,7 @@ func TestContextCancellationDuringRetryBackoff(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
_, err = client.UploadBundle(ctx, UploadBundleOptions{Root: root, IdempotencyKey: "cancel"}) _, err = client.UploadBundle(ctx, UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "cancel"})
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
t.Fatalf("UploadBundle() error = %v, want context.Canceled", err) t.Fatalf("UploadBundle() error = %v, want context.Canceled", err)
} }
@@ -446,7 +528,7 @@ func TestInvalidCallerIdempotencyKeyPreventsHTTPRequest(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient() error = %v", err) t.Fatalf("NewClient() error = %v", err)
} }
if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{Root: root, IdempotencyKey: "bad key"}); err == nil { if _, err := client.UploadBundle(context.Background(), UploadBundleOptions{PipelineID: "reports", Root: root, IdempotencyKey: "bad key"}); err == nil {
t.Fatal("UploadBundle() error = nil, want invalid key error") t.Fatal("UploadBundle() error = nil, want invalid key error")
} }
if got := requests.Load(); got != 0 { if got := requests.Load(); got != 0 {

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

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

View File

@@ -30,6 +30,7 @@ type RetryOptions struct {
} }
type UploadBundleOptions struct { type UploadBundleOptions struct {
PipelineID string
Root string Root string
Validate bool Validate bool
DisableValidation bool DisableValidation bool
@@ -37,6 +38,7 @@ type UploadBundleOptions struct {
} }
type UploadFilesOptions struct { type UploadFilesOptions struct {
PipelineID string
ID string ID string
Created time.Time Created time.Time
Files []bundle.BundleFile Files []bundle.BundleFile