# Distributor Operations ## Normal Workflow Validate a source bundle: ```sh go run ./cmd/distributor validate examples/source-bundle ``` Preview a local publication: ```sh go run ./cmd/distributor run --config examples/local-publish.yml --dry-run ``` Run the local publication: ```sh go run ./cmd/distributor run --config examples/local-publish.yml ``` Run the local HTML publication: ```sh go run ./cmd/distributor run --config examples/local-html.yml ``` Run the local `index.html` publication: ```sh go run ./cmd/distributor run --config examples/local-index.yml ``` Preview local fan-out publication: ```sh go run ./cmd/distributor run --config examples/fan-out.yml --dry-run ``` Preview local archive-plus-latest publication: ```sh go run ./cmd/distributor run --config examples/archive-and-latest.yml --dry-run ``` Preview a run for automation: ```sh go run ./cmd/distributor run --config examples/fan-out.yml --dry-run --format json ``` Preview an environment-gated SSH destination config after editing it for an SSH/SFTP endpoint you control: ```sh go run ./cmd/distributor run --config examples/ssh-destination.yml --dry-run ``` Preview an environment-gated S3 destination config after editing it for an S3-compatible endpoint and bucket you control: ```sh go run ./cmd/distributor run --config examples/s3-destination.yml --dry-run ``` Validate one configured source without opening destinations: ```sh go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle ``` ## Filesystem Layout Source bundles are discovered beneath the configured source root. Each bundle is a directory containing `manifest.json`. Destination bundle paths are configured per destination with `path_mapping.mode`. The default mode, `preserve_relative`, preserves the source bundle path relative to the source root. A source bundle at the source root publishes to the destination root. A source bundle under `daily/` publishes under `daily/` at that destination. The `fixed` mode publishes one selected source bundle at the destination backend root. A fixed destination with local `path: /srv/www/reports/latest` writes outputs and `.distributor.json` directly under `/srv/www/reports/latest`. Fixed destinations select the newest discovered source bundle by manifest `created` timestamp, with the source-root-relative bundle path as the deterministic tie-breaker. The maintained local examples write under `workspace/`, which is ignored by Git. SSH backends use the configured remote `path` as the backend root. Source bundle discovery and destination bundle paths are relative to that root, using the same logical path rules as local storage. S3 backends use the configured bucket plus optional `prefix` as the backend root. Source bundle discovery and destination bundle paths are relative to that object-key prefix. Prefixes are object-key prefixes, not real directories. ## Destination State Each published destination bundle contains `.distributor.json`. This file is the managed sentinel and destination state record. It stores: - pipeline and destination identity; - publication timestamp; - source manifest used for publication; - copied source output metadata; - generated output metadata; - optional public URL metadata when destination links are configured. `manifest.json` from the source bundle is not copied as destination state. Do not edit `.distributor.json` by hand during normal operation. If it is missing or invalid while destination files remain, `distributor` treats the destination as unmanaged or conflicted. ## Go Producer Bundles Go producer applications can import `gitea.maximumdirect.net/eric/distributor/pkg/bundle` to create complete local source bundles with the same path, digest, timestamp, and validation rules used by `distributor`. The package also exposes digest helpers, including `ValidateDigest`, for producer code that needs to validate lowercase `sha256:<64 hex>` strings before writing manifests. Minimal producer-side bundle creation: ```go manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{ Root: outputDir, ID: "reports.example.2026-05-30", Files: []bundle.BundleFile{ {SourcePath: reportPath, Path: "report.md"}, {SourcePath: summaryPath, Path: "summary.txt"}, }, }) if err != nil { return err } ``` `WriteBundle` copies local producer files into a sibling temporary directory, writes `manifest.json`, validates the result, and promotes the completed bundle into place. It fails if `Root` already exists unless `Overwrite` is true. With overwrite enabled, it builds and validates the replacement before moving the existing root aside. Use `BuildManifest` and `WriteManifest` when a producer already wrote all bundle files into the final root. `BuildManifest` can preserve an explicit file order, or `Scan: true` can recursively include regular files under `Root` in deterministic slash-path order. Scan mode includes dotfiles, excludes files named `manifest.json` or `.distributor.json`, and rejects symlinks. Shell producers can create the same manifest through the CLI after writing bundle files: ```sh go run ./cmd/distributor manifest create --id reports.example.2026-05-30 go run ./cmd/distributor validate ``` Use repeated `--file` flags to preserve a specific file order. If no `--file` flags are provided, the command scans the bundle directory recursively using the same filtering rules as `pkg/bundle.BuildManifest`. ## Static HTML Publication Markdown-to-HTML publication can write sidecar files or a fixed `index.html`. Use sidecar mode when each Markdown source should keep a matching HTML filename: ```yaml publish: source: false html: true transform: markdown_to_html: enabled: true mode: sidecar ``` Use index mode for static-site destinations that should serve a bundle through `index.html`: ```yaml publish: source: false html: true transform: markdown_to_html: enabled: true mode: index input: report.md ``` If `input` is omitted in index mode, the source manifest must list exactly one Markdown file. Generated HTML is recorded in `.distributor.json` with `kind: generated`, `source_path`, `transform: markdown_to_html`, digest, and size metadata. ## Archive And Latest Fan-Out A pipeline can publish the same source to an archive destination and a stable latest destination: ```yaml pipelines: - id: reports source: backend: local path: /var/spool/distributor/reports destinations: - id: archive backend: local path: /srv/reports/archive path_mapping: mode: preserve_relative publish: source: true html: false - id: latest-html backend: local path: /srv/www/reports/latest path_mapping: mode: fixed links: base_url: https://reports.example.com/latest primary: auto publish: source: false html: true transform: markdown_to_html: enabled: true mode: index input: report.md ``` The archive destination plans every discovered source bundle at its source-relative path. The fixed latest destination plans only the newest discovered bundle and writes `index.html` plus `.distributor.json` at its backend root. ## Static Site URLs Use destination `links` when a destination backend root corresponds to a public HTTP or HTTPS URL: ```yaml links: base_url: https://reports.example.com/archive primary: auto ``` Distributor records URLs in `.distributor.json`; it does not publish notifications or infer URLs from local, SSH, or S3 backend fields. For archive-style destinations, URLs include the destination bundle path. A source bundle under `daily/brentwood/2026-06-01` with `base_url: https://reports.example.com/archive` can produce: ```text https://reports.example.com/archive/daily/brentwood/2026-06-01/report.html ``` For fixed destinations, URLs are rooted at `links.base_url`. A fixed HTML index destination with `base_url: https://reports.example.com/latest` records: ```text https://reports.example.com/latest/ ``` `index.html` outputs use directory-style URLs. Other outputs include their filename. The primary URL is selected from the published outputs using the destination `links.primary` policy. ## Source Validation and Inspection `validate` and `inspect` can operate on a local path or on one configured pipeline source. Configured source mode requires both `--config` and `--pipeline`; it loads the normal config, resolves `secrets.directory`, opens only the selected source backend, and does not open any destinations. Configured source validation is useful when producers write directly to SSH or S3 storage: ```sh go run ./cmd/distributor validate --config --pipeline go run ./cmd/distributor inspect --config --pipeline ``` Use `--bundle ` to validate or inspect one source-root-relative bundle directory: ```sh go run ./cmd/distributor validate \ --config \ --pipeline \ --bundle daily/2026-06-01 ``` For configured SSH sources, host key and authentication behavior matches `run`. For configured S3 sources, endpoint, bucket, prefix, region, path-style, explicit credential environment variables, and `secrets.directory` handling match `run`. ## Dry Runs `--dry-run` loads and validates config, discovers source bundles, inspects destination state, plans outputs, and prints summary lines. It does not write output files, destination state, or SSH `known_hosts` entries. Dry-run output is useful before publishing to confirm actions such as `publish_new`, `replace_older`, `force_replace`, `skip_same`, and `skip_destination_newer`. Destination action lines include the destination backend, so mixed local, SSH, and S3 fan-out runs can be audited before publication. Fixed path destinations add `path_mapping=fixed target=.` to planned action lines. Dry-run also prints a warning with the fixed destination candidate count and selected source bundle; destructive fixed replacements print an additional warning that the destination root would be replaced. Use `--format json` when another process needs stable run data. JSON output includes warnings, pipeline summaries, destination actions, destination bundle paths, path mapping modes, optional link URLs, output records, final counters, and partial failure records. The summary includes `fixed_path`. If one destination fails after planning or execution begins, JSON output still contains the successful and failed destination records with `ok: false`, and the command exits non-zero. ## Retry and Replacement Behavior If a destination has matching `.distributor.json`, publication skips it as already published. If destination state is older than the source manifest and transfer policy allows replacement, publication deletes only managed outputs recorded in `.distributor.json` plus the state file, then writes the new outputs and state. If destination state is newer than the source manifest, the default behavior is to skip. If destination state has the same source id and created timestamp but a different digest, publication fails as a conflict. If a destination path has files but no valid `.distributor.json`, publication fails as unmanaged content unless the current run explicitly uses `--force`. ## Force Workflow Use `--force` only after a dry run shows the intended `force_replace` action: ```sh go run ./cmd/distributor run --config --dry-run --force go run ./cmd/distributor run --config --force ``` Forced replacement can overwrite unmanaged non-empty destination paths. Destination state conflicts require `transfer.on_conflict: replace` plus `--force`. Newer destination state requires `transfer.on_destination_newer: replace` plus `--force`. Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete above that bundle path. For fixed destinations, the destination bundle path is the backend root, so forced replacement may clear that configured root but not its parent path, sibling directories, or anything outside the configured S3 bucket and prefix. Force is per run only and has no config default. ## Failure Handling If one destination fails in a fan-out run, independent later destinations are still planned and executed. The command exits non-zero after printing the final status if any destination failed. Errors include the pipeline id, destination id, destination backend, and bundle path where applicable. In JSON mode, destination failures after planning or execution begins are reported in the top-level `errors` array and in the run result while preserving a non-zero exit code. Fatal setup errors such as an unreadable config or invalid secrets directory write no JSON document. If a write fails during publication, `distributor` attempts to remove outputs written during that failed attempt so a retry does not see those partial outputs as unmanaged destination content. After a successful publish or replacement, the internal notifier hook runs. The current default notifier is a no-op. Skipped destinations do not invoke it. ## SSH Operation Notes SSH execution uses SFTP over `golang.org/x/crypto/ssh` and `github.com/pkg/sftp`. It does not shell out to `ssh`, `scp`, or `rsync`. Configure `ssh_key_file`, an SSH agent, or both. Agent identities are attempted first, followed by the configured key file. YAML password authentication is not supported. The default host key policy is `accept-new`. New host keys are written to `known_hosts` when the file path is writable. During `--dry-run`, unknown host keys may be accepted for the current connection but are not written to `known_hosts`; a later non-dry-run may persist the same key. Changed host keys are fatal for both `strict` and `accept-new`. The `off` policy disables host key checking and `run` prints a warning when stdout is enabled. Recovery boundaries are the same as local storage: replacement deletes only managed output paths recorded in `.distributor.json` plus the state file, and failed writes are cleaned up where practical. Distributor never performs broad recursive remote deletion. ## S3 Operation Notes S3 execution uses the AWS SDK for Go v2. Configure `endpoint`, `bucket`, optional `prefix`, optional `region`, and optional explicit credential environment variable names. When explicit credential env names are configured, both variables must resolve to non-empty values through the real process environment or `secrets.directory`. When they are omitted, the AWS SDK default credential chain is used as-is. Normal replacement and failed-write cleanup delete only managed output objects recorded in `.distributor.json` plus the state object. Forced replacement deletes objects under the bounded destination bundle prefix. For fixed destinations, that prefix is the configured bucket plus optional `prefix`. Distributor does not manage bucket versioning or delete markers. ## Secrets Directory Configure `secrets.directory` when credential values should come from mounted files, such as deployment secrets: ```yaml secrets: directory: /run/secrets/distributor ``` The directory is loaded during `run` and configured-source `validate` or `inspect` before any backend is opened. If the directory is missing, unreadable, or contains an invalid secret filename, the command fails before storage work starts. Real process environment values take precedence over files with the same name. If the values differ and stdout is enabled, `run` and configured-source diagnostics print a warning naming the ignored secret file variable without printing either value. The process environment is not changed. ## Caveats External notification adapters are unavailable. Force overwrite behavior is available only through the explicit `run --force` workflow. For symptom-oriented fixes, see [troubleshooting](troubleshooting.md). For config details, see [configuration](config.md). For command syntax, see [CLI](cli.md).