Files
distributor/docs/operations.md

18 KiB

Distributor Operations

Normal Workflow

Validate a source bundle:

go run ./cmd/distributor validate examples/source-bundle

Preview a local publication:

go run ./cmd/distributor run --config examples/local-publish.yml --dry-run

Run the local publication:

go run ./cmd/distributor run --config examples/local-publish.yml

Run the local HTML publication:

go run ./cmd/distributor run --config examples/local-html.yml

Run the local index.html publication:

go run ./cmd/distributor run --config examples/local-index.yml

Preview local fan-out publication:

go run ./cmd/distributor run --config examples/fan-out.yml --dry-run

Preview local archive-plus-latest publication:

go run ./cmd/distributor run --config examples/archive-and-latest.yml --dry-run

Preview a run for automation:

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:

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:

go run ./cmd/distributor run --config examples/s3-destination.yml --dry-run

Validate one configured source without opening destinations:

go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle

HTTP Upload Workflow

distributor serve runs the HTTP upload API for pipelines whose source backend is http_upload. Each upload token maps to one configured pipeline. A valid archive is staged and validated before a run id is returned, then published through the same destination fan-out path used by local source runs.

Minimal local HTTP upload configuration:

server:
  http:
    bind: 127.0.0.1:8080
    staging_root: /var/spool/distributor
    max_upload_size: 20MB
    queue_size: 16
    max_concurrency: 1
    retention: 24h
secrets:
  directory: /run/secrets/distributor
pipelines:
  - id: reports
    source:
      backend: http_upload
      token_env: DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN
    destinations:
      - id: archive
        backend: local
        path: /srv/reports/archive

Create /run/secrets/distributor/DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN or set the real process environment variable before starting the server. Distributor does not read literal upload tokens from YAML.

Start the maintained local example:

DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN=<token> \
  go run ./cmd/distributor serve --config examples/http-upload-local.yml

Submit a tar or tar.gz source bundle:

curl -X POST http://127.0.0.1:8080/upload \
  -H "Authorization: Bearer $DISTRIBUTOR_EXAMPLE_UPLOAD_TOKEN" \
  -H "Content-Type: application/gzip" \
  --data-binary @bundle.tar.gz

Successful staging and admission returns a run id:

{"run_id":"reports.20260603T120000Z.abcdef12","status":"accepted"}

Poll status until it reaches succeeded or failed:

curl http://127.0.0.1:8080/runs/<run-id>

The status record includes the completed run report on successful publication or error details on failure. Status is memory-only and expires after server.http.retention; completed staged bundle directories are removed on expiry. Restarting the process clears upload status and queue state.

Malformed archives and invalid source bundles are rejected by POST /upload before a run id is issued.

Use GET /healthz for readiness after config and tokens load:

curl http://127.0.0.1:8080/healthz

The default bind address is private loopback. Put TLS, public routing, rate-limiting, and external access policy in a reverse proxy or deployment layer.

Filesystem Layout

Source bundles are discovered beneath the configured source root. Each bundle is a directory containing manifest.json.

Destination bundle paths are configured per destination with path_mapping.mode.

The default mode, preserve_relative, preserves the source bundle path relative to the source root. A source bundle at the source root publishes to the destination root. A source bundle under daily/ publishes under daily/ at that destination.

The fixed mode publishes one selected source bundle at the destination backend root. A fixed destination with local path: /srv/www/reports/latest writes outputs and .distributor.json directly under /srv/www/reports/latest. Fixed destinations select the newest discovered source bundle by manifest created timestamp, with the source-root-relative bundle path as the deterministic tie-breaker.

The maintained local examples write under workspace/, which is ignored by Git.

SSH backends use the configured remote path as the backend root. Source bundle discovery and destination bundle paths are relative to that root, using the same logical path rules as local storage.

S3 backends use the configured bucket plus optional prefix as the backend root. Source bundle discovery and destination bundle paths are relative to that object-key prefix. Prefixes are object-key prefixes, not real directories.

Destination State

Each published destination bundle contains .distributor.json. This file is the managed sentinel and destination state record. It stores:

  • pipeline and destination identity;
  • publication timestamp;
  • source manifest used for publication;
  • copied source output metadata;
  • generated output metadata;
  • optional public URL metadata when destination links are configured.

manifest.json from the source bundle is not copied as destination state.

Do not edit .distributor.json by hand during normal operation. If it is missing or invalid while destination files remain, distributor treats the destination as unmanaged or conflicted.

Go Producer Bundles

Go producer applications can import gitea.maximumdirect.net/eric/distributor/pkg/bundle to create complete local source bundles with the same path, digest, timestamp, and validation rules used by distributor. The package also exposes digest helpers, including ValidateDigest, for producer code that needs to validate lowercase sha256:<64 hex> strings before writing manifests.

Minimal producer-side bundle creation:

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:

go run ./cmd/distributor manifest create <bundle-path> --id reports.example.2026-05-30
go run ./cmd/distributor validate <bundle-path>

Use repeated --file flags to preserve a specific file order. If no --file flags are provided, the command scans the bundle directory recursively using the same filtering rules as pkg/bundle.BuildManifest.

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:

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:

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:

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:

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:

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:

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:

go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
go run ./cmd/distributor inspect --config <config-path> --pipeline <pipeline-id>

Use --bundle <path> to validate or inspect one source-root-relative bundle directory:

go run ./cmd/distributor validate \
  --config <config-path> \
  --pipeline <pipeline-id> \
  --bundle daily/2026-06-01

For configured SSH sources, host key and authentication behavior matches run. For configured S3 sources, endpoint, bucket, prefix, region, path-style, explicit credential environment variables, and secrets.directory handling match run.

Dry Runs

--dry-run loads and validates config, discovers source bundles, inspects destination state, plans outputs, and prints summary lines. It does not write output files, destination state, or SSH known_hosts entries.

Dry-run output is useful before publishing to confirm actions such as publish_new, replace_older, force_replace, skip_same, and skip_destination_newer.

Destination action lines include the destination backend, so mixed local, SSH, and S3 fan-out runs can be audited before publication. Fixed path destinations add path_mapping=fixed target=. to planned action lines. Dry-run also prints a warning with the fixed destination candidate count and selected source bundle; destructive fixed replacements print an additional warning that the destination root would be replaced.

Use --format json when another process needs stable run data. JSON output includes warnings, pipeline summaries, destination actions, destination bundle paths, path mapping modes, optional link URLs, output records, final counters, and partial failure records. The summary includes fixed_path. If one destination fails after planning or execution begins, JSON output still contains the successful and failed destination records with ok: false, and the command exits non-zero.

Retry and Replacement Behavior

If a destination has matching .distributor.json, publication skips it as already published.

If destination state is older than the source manifest and transfer policy allows replacement, publication deletes only managed outputs recorded in .distributor.json plus the state file, then writes the new outputs and state.

If destination state is newer than the source manifest, the default behavior is to skip. If destination state has the same source id and created timestamp but a different digest, publication fails as a conflict.

If a destination path has files but no valid .distributor.json, publication fails as unmanaged content unless the current run explicitly uses --force.

Force Workflow

Use --force only after a dry run shows the intended force_replace action:

go run ./cmd/distributor run --config <config-path> --dry-run --force
go run ./cmd/distributor run --config <config-path> --force

Forced replacement can overwrite unmanaged non-empty destination paths. Destination state conflicts require transfer.on_conflict: replace plus --force. Newer destination state requires transfer.on_destination_newer: replace plus --force.

Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete above that bundle path. For 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:

secrets:
  directory: /run/secrets/distributor

The directory is loaded during run, serve, and configured-source validate or inspect before credential-consuming work starts. If the directory is missing, unreadable, or contains an invalid secret filename, the command fails before storage work starts.

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. For config details, see configuration. For command syntax, see CLI.