Add HTTP upload server and serve command
This commit is contained in:
41
docs/cli.md
41
docs/cli.md
@@ -14,6 +14,7 @@ This discovers the example source bundle and publishes source files to `workspac
|
|||||||
distributor [--help]
|
distributor [--help]
|
||||||
distributor version [--format text|json]
|
distributor version [--format text|json]
|
||||||
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
||||||
|
distributor serve [--config <path>]
|
||||||
distributor validate [--format text|json] <path>
|
distributor validate [--format text|json] <path>
|
||||||
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
||||||
distributor inspect [--format text|json] <path>
|
distributor inspect [--format text|json] <path>
|
||||||
@@ -23,11 +24,12 @@ distributor manifest create <bundle-path> --id <bundle-id> [options]
|
|||||||
|
|
||||||
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
||||||
- `run`: loads a YAML config, discovers source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
|
- `run`: loads a YAML config, discovers source bundles, plans each configured destination, writes selected outputs unless `--dry-run` is set, and prints a final status summary.
|
||||||
|
- `serve`: loads a YAML config, resolves HTTP upload bearer tokens, and runs the HTTP upload API.
|
||||||
- `validate`: validates a local source bundle directory, a local source bundle tree, or one configured pipeline source.
|
- `validate`: validates a local source bundle directory, a local source bundle tree, or one configured pipeline source.
|
||||||
- `inspect`: validates source bundles and prints normalized bundle metadata for a local path or one configured pipeline source.
|
- `inspect`: validates source bundles and prints normalized bundle metadata for a local path or one configured pipeline source.
|
||||||
- `manifest create`: creates `manifest.json` for a local source bundle directory.
|
- `manifest create`: creates `manifest.json` for a local source bundle directory.
|
||||||
|
|
||||||
`validate` and `inspect` have two mutually exclusive modes: a local path shortcut, or configured source mode with `--config <path> --pipeline <id>`. Configured source mode opens only the selected pipeline source and supports configured `local`, `ssh`, and `s3` sources. It does not open destinations. `run` executes configured sources and destinations.
|
`validate` and `inspect` have two mutually exclusive modes: a local path shortcut, or configured source mode with `--config <path> --pipeline <id>`. Configured source mode opens only the selected pipeline source and supports configured `local`, `ssh`, and `s3` sources. It does not open destinations. `run` executes configured `local`, `ssh`, and `s3` sources and destinations. `serve` executes configured `http_upload` sources through the upload API and normal destination fan-out.
|
||||||
|
|
||||||
## Flag reference
|
## Flag reference
|
||||||
|
|
||||||
@@ -49,6 +51,10 @@ Output-producing subcommands:
|
|||||||
- `--dry-run`: load config, discover bundles, inspect destination state, print planned actions and final status, and do not write output files, destination state, or SSH `known_hosts` entries.
|
- `--dry-run`: load config, discover bundles, inspect destination state, print planned actions and final status, and do not write output files, destination state, or SSH `known_hosts` entries.
|
||||||
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
||||||
|
|
||||||
|
`serve` flags:
|
||||||
|
|
||||||
|
- `--config <path>`: config file to load. If omitted, `serve` uses `/usr/local/etc/distributor/config.yml`.
|
||||||
|
|
||||||
`validate` and `inspect` configured source flags:
|
`validate` and `inspect` configured source flags:
|
||||||
|
|
||||||
- `--config <path>`: config file to load for source validation or inspection. Required in configured source mode.
|
- `--config <path>`: config file to load for source validation or inspection. Required in configured source mode.
|
||||||
@@ -127,6 +133,39 @@ Publish the local HTML example:
|
|||||||
go run ./cmd/distributor run --config examples/local-html.yml
|
go run ./cmd/distributor run --config examples/local-html.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Start the HTTP upload API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor serve --config <config-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload an archive to the configured `http_upload` pipeline associated with a bearer token:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:8080/upload \
|
||||||
|
-H "Authorization: Bearer $DISTRIBUTOR_UPLOAD_TOKEN" \
|
||||||
|
-H "Content-Type: application/gzip" \
|
||||||
|
--data-binary @bundle.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
The upload response is accepted asynchronously:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"run_id":"reports.20260603T120000Z.abcdef12","status":"accepted"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check upload status:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:8080/runs/<run-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Check server readiness:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:8080/healthz
|
||||||
|
```
|
||||||
|
|
||||||
Preview local fan-out publication:
|
Preview local fan-out publication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
## Config File Location
|
## Config File Location
|
||||||
|
|
||||||
`distributor run --config <path>` loads the YAML config at the provided path.
|
`distributor run --config <path>` and `distributor serve --config <path>` load
|
||||||
|
the YAML config at the provided path.
|
||||||
|
|
||||||
If `--config` is omitted, `run` uses:
|
If `--config` is omitted, both commands use:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/usr/local/etc/distributor/config.yml
|
/usr/local/etc/distributor/config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Config parsing rejects unknown YAML fields. The executable `run` backends are `local`, `ssh`, and `s3`. The schema also accepts `http_upload` as a source-only ingestion backend configuration.
|
Config parsing rejects unknown YAML fields. The executable `run` backends are
|
||||||
|
`local`, `ssh`, and `s3`. The `serve` command executes `http_upload` sources
|
||||||
|
through the HTTP upload API and normal destination fan-out.
|
||||||
|
|
||||||
## Minimal Local Config
|
## Minimal Local Config
|
||||||
|
|
||||||
@@ -62,7 +65,9 @@ pipelines:
|
|||||||
|
|
||||||
## HTTP Upload Source Configuration
|
## HTTP Upload Source Configuration
|
||||||
|
|
||||||
HTTP upload sources are configured as pipeline sources only. They are not valid destination backends.
|
HTTP upload sources are configured as pipeline sources only. They are not valid
|
||||||
|
destination backends. `distributor serve` maps each configured upload token to
|
||||||
|
exactly one `http_upload` pipeline.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
server:
|
server:
|
||||||
@@ -92,6 +97,50 @@ pipelines:
|
|||||||
|
|
||||||
`source.max_upload_size` is optional. When omitted, it defaults to `server.http.max_upload_size`.
|
`source.max_upload_size` is optional. When omitted, it defaults to `server.http.max_upload_size`.
|
||||||
|
|
||||||
|
The server resolves each `token_env` through the real process environment and
|
||||||
|
the configured `secrets.directory` resolver. Startup fails if any configured
|
||||||
|
upload token is missing, empty, or resolves to the same value as another upload
|
||||||
|
pipeline. Token values are not read from YAML and are not printed in API
|
||||||
|
responses.
|
||||||
|
|
||||||
|
## HTTP Upload API
|
||||||
|
|
||||||
|
`distributor serve` binds to `server.http.bind`, which defaults to
|
||||||
|
`127.0.0.1:8080`.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `GET /healthz`: returns readiness status after config and upload tokens load.
|
||||||
|
- `POST /upload`: accepts one tar or tar.gz source bundle archive.
|
||||||
|
- `GET /runs/<run_id>`: returns an in-memory upload status record, or `404` if the run id is unknown or expired.
|
||||||
|
|
||||||
|
`POST /upload` authenticates with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The token selects the configured `http_upload` pipeline. Producers do not send a
|
||||||
|
pipeline id. Requests with a submitted `pipeline` or `pipeline_id` query value
|
||||||
|
are rejected.
|
||||||
|
|
||||||
|
Accepted upload content types:
|
||||||
|
|
||||||
|
- `application/x-tar`
|
||||||
|
- `application/gzip`
|
||||||
|
- `application/x-gzip`
|
||||||
|
|
||||||
|
Accepted uploads return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"run_id":"<id>","status":"accepted"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The run id can be queried through `GET /runs/<run_id>` while the status record
|
||||||
|
is retained in memory. Completed records expire after `server.http.retention`;
|
||||||
|
expiration also removes committed staged bundle directories for completed
|
||||||
|
uploads.
|
||||||
|
|
||||||
## HTML Publication
|
## HTML Publication
|
||||||
|
|
||||||
To publish generated sidecar HTML from Markdown files:
|
To publish generated sidecar HTML from Markdown files:
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ same destination fan-out path as normal runs.
|
|||||||
source. They share source backend construction with run workflows and never open
|
source. They share source backend construction with run workflows and never open
|
||||||
destination backends.
|
destination backends.
|
||||||
|
|
||||||
|
`Serve` is the CLI-facing HTTP upload server entrypoint. It loads config,
|
||||||
|
loads the configured secrets directory, resolves upload bearer tokens for
|
||||||
|
configured `http_upload` sources, creates an `UploadCoordinator`, binds
|
||||||
|
`server.http.bind`, and serves the upload API until its context is cancelled.
|
||||||
|
|
||||||
## Run Reports
|
## Run Reports
|
||||||
|
|
||||||
`RunReport` is the structured result model for run workflows. It includes
|
`RunReport` is the structured result model for run workflows. It includes
|
||||||
@@ -116,6 +121,32 @@ Completed records retain the final run report or error text until
|
|||||||
their committed staged bundle directories. The coordinator is memory-only and
|
their committed staged bundle directories. The coordinator is memory-only and
|
||||||
does not persist queue state, status records, or run reports.
|
does not persist queue state, status records, or run reports.
|
||||||
|
|
||||||
|
## HTTP Upload Server
|
||||||
|
|
||||||
|
The HTTP upload server is app-layer transport wiring around
|
||||||
|
`UploadCoordinator`. It owns request authentication, route dispatch, HTTP status
|
||||||
|
mapping, and JSON response projection. Bundle staging and publication remain in
|
||||||
|
the coordinator and staged-source run path.
|
||||||
|
|
||||||
|
Server startup resolves every configured `http_upload` source `token_env`
|
||||||
|
through the config-owned environment resolver after `secrets.directory` has
|
||||||
|
been loaded. Startup fails when a token is missing, empty, or duplicates another
|
||||||
|
upload pipeline token. Error messages identify environment variable names and
|
||||||
|
pipeline ids, but not token values.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `GET /healthz`: returns `200` after config, secrets, tokens, coordinator, and route setup succeed.
|
||||||
|
- `POST /upload`: accepts authenticated tar and tar.gz archives and returns an accepted run id.
|
||||||
|
- `GET /runs/<run_id>`: returns the current in-memory upload status record or `404`.
|
||||||
|
|
||||||
|
The upload token maps to exactly one configured pipeline. Producers do not
|
||||||
|
submit pipeline ids, and submitted `pipeline` or `pipeline_id` query values are
|
||||||
|
rejected. Full queues are rejected before the request body is read. Oversized
|
||||||
|
uploads, unsupported content types, invalid bearer tokens, full queues, and
|
||||||
|
unknown status records are mapped to stable HTTP status codes without returning
|
||||||
|
secret token values.
|
||||||
|
|
||||||
## Coordination
|
## Coordination
|
||||||
|
|
||||||
`PipelineRunCoordinator` wraps `RunPipeline` with in-memory admission control.
|
`PipelineRunCoordinator` wraps `RunPipeline` with in-memory admission control.
|
||||||
@@ -171,6 +202,8 @@ Run helpers are grouped by responsibility:
|
|||||||
- `run_notify.go`: notification event projection and action filtering.
|
- `run_notify.go`: notification event projection and action filtering.
|
||||||
- `run_coordinator.go`: in-memory run admission, run IDs, status records, and duplicate-run errors.
|
- `run_coordinator.go`: in-memory run admission, run IDs, status records, and duplicate-run errors.
|
||||||
- `upload_coordinator.go`: in-memory upload admission, queueing, status tracking, staging handoff, and staged-source execution.
|
- `upload_coordinator.go`: in-memory upload admission, queueing, status tracking, staging handoff, and staged-source execution.
|
||||||
|
- `upload_http.go`: HTTP upload authentication, routes, JSON response projection, and HTTP error mapping.
|
||||||
|
- `serve.go`: config/secrets loading and HTTP server startup.
|
||||||
- `backends.go`: app-level backend factory wiring.
|
- `backends.go`: app-level backend factory wiring.
|
||||||
- `transforms.go`: app-level transform registry wiring.
|
- `transforms.go`: app-level transform registry wiring.
|
||||||
- `source_select.go`: configured-source selection shared by `validate` and `inspect`.
|
- `source_select.go`: configured-source selection shared by `validate` and `inspect`.
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ Input is a YAML file containing optional `server`, optional `secrets`, and requi
|
|||||||
|
|
||||||
Known-field checking rejects misspelled or unknown YAML keys before defaults and validation run.
|
Known-field checking rejects misspelled or unknown YAML keys before defaults and validation run.
|
||||||
|
|
||||||
`LoadFile` does not read secret files. `Run` loads the configured secrets directory after config validation and before backend construction.
|
`LoadFile` does not read secret files. App entrypoints load the configured
|
||||||
|
secrets directory after config validation and before credential-consuming work.
|
||||||
|
|
||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
@@ -58,13 +59,13 @@ Destination links are optional. When a `links` block is present, `base_url` is r
|
|||||||
|
|
||||||
## Executable support boundary
|
## Executable support boundary
|
||||||
|
|
||||||
Config validation accepts `local`, `ssh`, `s3`, and source-only `http_upload` backend shapes. Runtime execution opens `local`, `ssh`, and `s3` through `internal/app`.
|
Config validation accepts `local`, `ssh`, `s3`, and source-only `http_upload` backend shapes. Runtime `run`, `validate`, and `inspect` workflows open `local`, `ssh`, and `s3` through `internal/app`. Runtime `serve` workflows execute `http_upload` sources through the app upload coordinator and HTTP server.
|
||||||
|
|
||||||
SSH config uses structured fields: `host`, optional `user`, optional `port`, `path`, optional `ssh_key_file`, optional `known_hosts`, and optional `host_key_policy`. `host_key_policy` accepts YAML booleans and strings and normalizes `true`/`strict`, `accept-new`, and `false`/`off`.
|
SSH config uses structured fields: `host`, optional `user`, optional `port`, `path`, optional `ssh_key_file`, optional `known_hosts`, and optional `host_key_policy`. `host_key_policy` accepts YAML booleans and strings and normalizes `true`/`strict`, `accept-new`, and `false`/`off`.
|
||||||
|
|
||||||
S3 config requires `endpoint` and `bucket`, normalizes optional `prefix`, defaults `region` to `us-east-1`, and defaults omitted `force_path_style` to `true` while preserving explicit `false`.
|
S3 config requires `endpoint` and `bucket`, normalizes optional `prefix`, defaults `region` to `us-east-1`, and defaults omitted `force_path_style` to `true` while preserving explicit `false`.
|
||||||
|
|
||||||
HTTP upload config is source-only. Config owns its YAML shape, defaulting, size and duration parsing, and validation. The config package does not resolve `token_env`, authenticate requests, stage uploads, or execute HTTP upload sources.
|
HTTP upload config is source-only. Config owns its YAML shape, defaulting, size and duration parsing, and validation. The config package does not authenticate requests, stage uploads, or execute HTTP upload sources. The app layer resolves `token_env` through the config-owned environment resolver before starting the HTTP server.
|
||||||
|
|
||||||
## Secrets and credential resolution
|
## Secrets and credential resolution
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ Safe fix: compare the file to the reference in [configuration](config.md) and re
|
|||||||
|
|
||||||
## `validate config ... backend ... is unsupported`
|
## `validate config ... backend ... is unsupported`
|
||||||
|
|
||||||
Likely cause: a source or destination uses a backend name other than `local`, `ssh`, or `s3`.
|
Likely cause: a source or destination uses an unsupported backend name, or a
|
||||||
|
command is trying to execute a backend that is valid only for another workflow.
|
||||||
|
`run`, `validate`, and `inspect` execute `local`, `ssh`, and `s3` sources.
|
||||||
|
`serve` executes `http_upload` sources.
|
||||||
|
|
||||||
Diagnostic:
|
Diagnostic:
|
||||||
|
|
||||||
@@ -34,7 +37,127 @@ Diagnostic:
|
|||||||
rg -n "backend:" <config-path>
|
rg -n "backend:" <config-path>
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe fix: use `backend: local`, `backend: ssh`, or `backend: s3` for executable workflows.
|
Safe fix: use `backend: local`, `backend: ssh`, or `backend: s3` for normal
|
||||||
|
source and destination workflows. Use `backend: http_upload` only for sources
|
||||||
|
handled by `distributor serve`.
|
||||||
|
|
||||||
|
## `bind HTTP server ... address already in use`
|
||||||
|
|
||||||
|
Likely cause: another process is already listening on `server.http.bind`.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ss -ltnp | rg '<port>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: stop the conflicting process or configure a different
|
||||||
|
`server.http.bind` value. The default bind address is `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
## `upload token environment variable ... is not set`
|
||||||
|
|
||||||
|
Likely cause: a configured `http_upload` source references `token_env`, but the
|
||||||
|
variable is absent from both the real process environment and
|
||||||
|
`secrets.directory`.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
env | cut -d= -f1 | rg '^<token-variable>$'
|
||||||
|
ls -l <secrets-directory>/<token-variable>
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: set the real environment variable or create a readable
|
||||||
|
secrets-directory file with the same name. Do not place literal token values in
|
||||||
|
YAML.
|
||||||
|
|
||||||
|
## `upload token environment variables ... resolve to the same value`
|
||||||
|
|
||||||
|
Likely cause: two configured `http_upload` pipelines resolve to the same bearer
|
||||||
|
token value.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rg -n 'token_env:' <config-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: assign a distinct non-empty token value to each `http_upload`
|
||||||
|
pipeline. Distributor does not print the duplicate token value.
|
||||||
|
|
||||||
|
## `POST /upload` returns `401`
|
||||||
|
|
||||||
|
Likely cause: the request is missing `Authorization: Bearer <token>` or the
|
||||||
|
token does not match any configured `http_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.
|
||||||
|
|
||||||
|
## `POST /upload` returns `413`
|
||||||
|
|
||||||
|
Likely cause: the request body exceeds the selected pipeline's
|
||||||
|
`source.max_upload_size` or the default `server.http.max_upload_size`.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -lh bundle.tar bundle.tar.gz
|
||||||
|
rg -n 'max_upload_size:' <config-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: upload a smaller archive, remove unnecessary files from the source
|
||||||
|
bundle, or raise the configured upload size limit.
|
||||||
|
|
||||||
|
## `POST /upload` returns `415`
|
||||||
|
|
||||||
|
Likely cause: the upload uses an unsupported content type. The server accepts
|
||||||
|
uncompressed tar and gzip-compressed tar archives only.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
file bundle.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: send `Content-Type: application/x-tar`, `application/gzip`, or
|
||||||
|
`application/x-gzip`, matching the archive format.
|
||||||
|
|
||||||
|
## `POST /upload` returns `503`
|
||||||
|
|
||||||
|
Likely cause: the in-memory upload queue is full.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rg -n 'queue_size|max_concurrency' <config-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: retry after active uploads finish, or increase `server.http.queue_size`
|
||||||
|
for the deployment.
|
||||||
|
|
||||||
|
## `GET /runs/<run_id>` returns `404`
|
||||||
|
|
||||||
|
Likely cause: the run id is wrong, the process restarted, or the completed
|
||||||
|
status record expired after `server.http.retention`.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i http://127.0.0.1:8080/runs/<run-id>
|
||||||
|
rg -n 'retention:' <config-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: use the exact `run_id` returned by `POST /upload`. If status retention
|
||||||
|
is too short for operators, increase `server.http.retention`.
|
||||||
|
|
||||||
## `--format: format must be text or json`
|
## `--format: format must be text or json`
|
||||||
|
|
||||||
|
|||||||
62
internal/app/serve.go
Normal file
62
internal/app/serve.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServeOptions struct {
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Serve(ctx context.Context, options ServeOptions) error {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := options.ConfigPath
|
||||||
|
if configPath == "" {
|
||||||
|
configPath = config.DefaultConfigPath
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := newUploadHTTPHandler(ctx, cfg, secretLoad.Environment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", cfg.Server.HTTP.Bind)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bind HTTP server %q: %w", cfg.Server.HTTP.Bind, err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
server := &http.Server{Handler: handler}
|
||||||
|
shutdownDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(shutdownDone)
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = server.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = server.Serve(listener)
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
<-shutdownDone
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -201,6 +201,13 @@ func (coordinator *UploadCoordinator) Expire() []UploadRunRecord {
|
|||||||
return coordinator.expireLocked(coordinator.now().UTC())
|
return coordinator.expireLocked(coordinator.now().UTC())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (coordinator *UploadCoordinator) CanAccept() bool {
|
||||||
|
coordinator.mu.Lock()
|
||||||
|
defer coordinator.mu.Unlock()
|
||||||
|
coordinator.expireLocked(coordinator.now().UTC())
|
||||||
|
return len(coordinator.pending) < coordinator.queueSize
|
||||||
|
}
|
||||||
|
|
||||||
func (coordinator *UploadCoordinator) QueueDepth() int {
|
func (coordinator *UploadCoordinator) QueueDepth() int {
|
||||||
coordinator.mu.Lock()
|
coordinator.mu.Lock()
|
||||||
defer coordinator.mu.Unlock()
|
defer coordinator.mu.Unlock()
|
||||||
|
|||||||
209
internal/app/upload_http.go
Normal file
209
internal/app/upload_http.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/ingest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type uploadCoordinator interface {
|
||||||
|
CanAccept() bool
|
||||||
|
Submit(context.Context, UploadRequest) (UploadRunRecord, error)
|
||||||
|
Status(UploadRunID) (UploadRunRecord, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadHTTPHandler struct {
|
||||||
|
coordinator uploadCoordinator
|
||||||
|
tokens map[string]string
|
||||||
|
limits map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadAcceptedResponse struct {
|
||||||
|
RunID UploadRunID `json:"run_id"`
|
||||||
|
Status UploadStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUploadHTTPHandler(ctx context.Context, cfg config.Config, environment config.Environment) (http.Handler, error) {
|
||||||
|
config.ApplyDefaults(&cfg)
|
||||||
|
tokens, limits, err := resolveUploadTokens(cfg, environment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uploadHTTPHandler{
|
||||||
|
coordinator: NewUploadCoordinator(ctx, cfg),
|
||||||
|
tokens: tokens,
|
||||||
|
limits: limits,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveUploadTokens(cfg config.Config, environment config.Environment) (map[string]string, map[string]int64, error) {
|
||||||
|
tokens := make(map[string]string)
|
||||||
|
limits := make(map[string]int64)
|
||||||
|
for _, pipeline := range cfg.Pipelines {
|
||||||
|
if pipeline.Source.Backend != config.BackendHTTPUpload {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokenName := pipeline.Source.Upload.TokenEnv
|
||||||
|
token, ok := environment.Lookup(tokenName)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("upload token environment variable %s is not set", tokenName)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, nil, fmt.Errorf("upload token environment variable %s is empty", tokenName)
|
||||||
|
}
|
||||||
|
if existing, exists := tokens[token]; exists {
|
||||||
|
return nil, nil, fmt.Errorf("upload token environment variables for pipelines %s and %s resolve to the same value", existing, pipeline.ID)
|
||||||
|
}
|
||||||
|
tokens[token] = pipeline.ID
|
||||||
|
limits[pipeline.ID] = int64(*pipeline.Source.Upload.MaxUploadSize)
|
||||||
|
}
|
||||||
|
return tokens, limits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler uploadHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/healthz":
|
||||||
|
handler.handleHealth(w)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/upload":
|
||||||
|
handler.handleUpload(w, r)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/runs/"):
|
||||||
|
handler.handleRunStatus(w, r)
|
||||||
|
default:
|
||||||
|
writeHTTPError(w, http.StatusNotFound, "not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler uploadHTTPHandler) handleHealth(w http.ResponseWriter) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler uploadHTTPHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Has("pipeline") || r.URL.Query().Has("pipeline_id") {
|
||||||
|
writeHTTPError(w, http.StatusBadRequest, "pipeline id is not accepted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pipelineID, ok := handler.authenticate(r.Header.Get("Authorization"))
|
||||||
|
if !ok {
|
||||||
|
writeHTTPError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if !supportedUploadContentType(contentType) {
|
||||||
|
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !handler.coordinator.CanAccept() {
|
||||||
|
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readUploadBody(r.Body, handler.limits[pipelineID])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ingest.ErrUploadTooLarge) {
|
||||||
|
writeHTTPError(w, http.StatusRequestEntityTooLarge, "upload exceeds maximum size")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeHTTPError(w, http.StatusBadRequest, "read upload body failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, err := handler.coordinator.Submit(r.Context(), UploadRequest{
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
ContentType: contentType,
|
||||||
|
Body: bytes.NewReader(body),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeUploadSubmitError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, uploadAcceptedResponse{
|
||||||
|
RunID: record.ID,
|
||||||
|
Status: UploadStatusAccepted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler uploadHTTPHandler) handleRunStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawRunID := strings.TrimPrefix(r.URL.Path, "/runs/")
|
||||||
|
if rawRunID == "" || strings.Contains(rawRunID, "/") {
|
||||||
|
writeHTTPError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record, ok := handler.coordinator.Status(UploadRunID(rawRunID))
|
||||||
|
if !ok {
|
||||||
|
writeHTTPError(w, http.StatusNotFound, "run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler uploadHTTPHandler) authenticate(header string) (string, bool) {
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(header, prefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
pipelineID, ok := handler.tokens[token]
|
||||||
|
return pipelineID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportedUploadContentType(contentType string) bool {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
mediaType = contentType
|
||||||
|
}
|
||||||
|
switch mediaType {
|
||||||
|
case ingest.ContentTypeTar, ingest.ContentTypeGzip, ingest.ContentTypeXGzip:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUploadBody(body io.Reader, maxSize int64) ([]byte, error) {
|
||||||
|
limited := &io.LimitedReader{R: body, N: maxSize + 1}
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxSize {
|
||||||
|
return nil, ingest.ErrUploadTooLarge
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUploadSubmitError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case IsUploadQueueFull(err):
|
||||||
|
writeHTTPError(w, http.StatusServiceUnavailable, "upload queue is full")
|
||||||
|
case errors.Is(err, ingest.ErrUploadTooLarge):
|
||||||
|
writeHTTPError(w, http.StatusRequestEntityTooLarge, "upload exceeds maximum size")
|
||||||
|
case errors.Is(err, ingest.ErrUnsupportedContentType):
|
||||||
|
writeHTTPError(w, http.StatusUnsupportedMediaType, "unsupported content type")
|
||||||
|
default:
|
||||||
|
writeHTTPError(w, http.StatusBadRequest, "upload rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHTTPError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, httpErrorResponse{Error: message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
331
internal/app/upload_http_test.go
Normal file
331
internal/app/upload_http_test.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeUploadCoordinator struct {
|
||||||
|
canAccept bool
|
||||||
|
submit func(context.Context, UploadRequest) (UploadRunRecord, error)
|
||||||
|
status func(UploadRunID) (UploadRunRecord, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake fakeUploadCoordinator) CanAccept() bool {
|
||||||
|
return fake.canAccept
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake fakeUploadCoordinator) Submit(ctx context.Context, request UploadRequest) (UploadRunRecord, error) {
|
||||||
|
if fake.submit == nil {
|
||||||
|
return UploadRunRecord{}, errors.New("unexpected submit")
|
||||||
|
}
|
||||||
|
return fake.submit(ctx, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fake fakeUploadCoordinator) Status(runID UploadRunID) (UploadRunRecord, bool) {
|
||||||
|
if fake.status == nil {
|
||||||
|
return UploadRunRecord{}, false
|
||||||
|
}
|
||||||
|
return fake.status(runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUploadTokensFailsForMissingAndDuplicateTokens(t *testing.T) {
|
||||||
|
cfg := uploadHTTPTestConfig()
|
||||||
|
|
||||||
|
_, _, err := resolveUploadTokens(cfg, config.NewEnvironment(nil, func(string) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "UPLOAD_TOKEN") {
|
||||||
|
t.Fatalf("resolveUploadTokens() error = %v, want missing UPLOAD_TOKEN", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Pipelines = append(cfg.Pipelines, config.Pipeline{
|
||||||
|
ID: "weekly",
|
||||||
|
Source: config.Backend{
|
||||||
|
Backend: config.BackendHTTPUpload,
|
||||||
|
Upload: config.HTTPUpload{TokenEnv: "OTHER_UPLOAD_TOKEN"},
|
||||||
|
},
|
||||||
|
Destinations: cfg.Pipelines[0].Destinations,
|
||||||
|
})
|
||||||
|
config.ApplyDefaults(&cfg)
|
||||||
|
secret := "super-secret-token"
|
||||||
|
_, _, err = resolveUploadTokens(cfg, uploadHTTPTestEnvironment(map[string]string{
|
||||||
|
"UPLOAD_TOKEN": secret,
|
||||||
|
"OTHER_UPLOAD_TOKEN": secret,
|
||||||
|
}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveUploadTokens() error = nil, want duplicate token error")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatalf("duplicate token error exposed secret value: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUploadHTTPHandlerAcceptsDefaultedConfig(t *testing.T) {
|
||||||
|
cfg := uploadHTTPTestConfig()
|
||||||
|
cfg.Server.HTTP.Bind = ""
|
||||||
|
cfg.Server.HTTP.StagingRoot = ""
|
||||||
|
cfg.Server.HTTP.MaxUploadSize = nil
|
||||||
|
cfg.Server.HTTP.QueueSize = 0
|
||||||
|
cfg.Server.HTTP.MaxConcurrency = 0
|
||||||
|
cfg.Server.HTTP.Retention = nil
|
||||||
|
cfg.Pipelines[0].Source.Upload.StagingPath = ""
|
||||||
|
cfg.Pipelines[0].Source.Upload.MaxUploadSize = nil
|
||||||
|
|
||||||
|
handler, err := newUploadHTTPHandler(context.Background(), cfg, uploadHTTPTestEnvironment(map[string]string{
|
||||||
|
"UPLOAD_TOKEN": "secret",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newUploadHTTPHandler() error = %v", err)
|
||||||
|
}
|
||||||
|
if handler == nil {
|
||||||
|
t.Fatal("newUploadHTTPHandler() = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadHTTPHandlerAuthenticatesAndAcceptsUpload(t *testing.T) {
|
||||||
|
var submitted UploadRequest
|
||||||
|
handler := uploadHTTPHandler{
|
||||||
|
coordinator: fakeUploadCoordinator{
|
||||||
|
canAccept: true,
|
||||||
|
submit: func(_ context.Context, request UploadRequest) (UploadRunRecord, error) {
|
||||||
|
submitted = request
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read submitted body: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "archive" {
|
||||||
|
t.Fatalf("submitted body = %q, want archive", body)
|
||||||
|
}
|
||||||
|
return UploadRunRecord{ID: "reports.20260603T120000Z.abcdef12", Status: UploadStatusAccepted}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokens: map[string]string{"valid-token": "reports"},
|
||||||
|
limits: map[string]int64{"reports": 1024},
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/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.StatusAccepted {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, http.StatusAccepted, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if submitted.PipelineID != "reports" {
|
||||||
|
t.Fatalf("submitted pipeline = %q, want reports", submitted.PipelineID)
|
||||||
|
}
|
||||||
|
var response uploadAcceptedResponse
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if response.RunID != "reports.20260603T120000Z.abcdef12" || response.Status != UploadStatusAccepted {
|
||||||
|
t.Fatalf("response = %#v, want accepted run id", response)
|
||||||
|
}
|
||||||
|
if strings.Contains(recorder.Body.String(), "valid-token") {
|
||||||
|
t.Fatalf("response exposed token: %q", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadHTTPHandlerRejectsUnauthorizedRequests(t *testing.T) {
|
||||||
|
handler := uploadHTTPHandler{
|
||||||
|
coordinator: fakeUploadCoordinator{canAccept: true},
|
||||||
|
tokens: map[string]string{"valid-token": "reports"},
|
||||||
|
limits: map[string]int64{"reports": 1024},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, authHeader := range []string{"", "Bearer wrong-token"} {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader("archive"))
|
||||||
|
request.Header.Set("Authorization", authHeader)
|
||||||
|
request.Header.Set("Content-Type", "application/x-tar")
|
||||||
|
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("auth %q status = %d, want %d", authHeader, recorder.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if strings.Contains(recorder.Body.String(), "valid-token") || strings.Contains(recorder.Body.String(), "wrong-token") {
|
||||||
|
t.Fatalf("unauthorized response exposed token: %q", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadHTTPHandlerRejectsUnsupportedOversizedFullQueueAndPipelineID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
canAccept bool
|
||||||
|
url string
|
||||||
|
contentType string
|
||||||
|
body io.Reader
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unsupported content type",
|
||||||
|
canAccept: true,
|
||||||
|
url: "/upload",
|
||||||
|
contentType: "application/zip",
|
||||||
|
body: strings.NewReader("archive"),
|
||||||
|
wantStatus: http.StatusUnsupportedMediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "oversized",
|
||||||
|
canAccept: true,
|
||||||
|
url: "/upload",
|
||||||
|
contentType: "application/x-tar",
|
||||||
|
body: strings.NewReader("too-large"),
|
||||||
|
wantStatus: http.StatusRequestEntityTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full queue",
|
||||||
|
canAccept: false,
|
||||||
|
url: "/upload",
|
||||||
|
contentType: "application/x-tar",
|
||||||
|
body: &countingReader{reader: strings.NewReader("archive")},
|
||||||
|
wantStatus: http.StatusServiceUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "submitted pipeline id",
|
||||||
|
canAccept: true,
|
||||||
|
url: "/upload?pipeline_id=reports",
|
||||||
|
contentType: "application/x-tar",
|
||||||
|
body: strings.NewReader("archive"),
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
handler := uploadHTTPHandler{
|
||||||
|
coordinator: fakeUploadCoordinator{
|
||||||
|
canAccept: tt.canAccept,
|
||||||
|
submit: func(context.Context, UploadRequest) (UploadRunRecord, error) {
|
||||||
|
t.Fatal("Submit should not be called")
|
||||||
|
return UploadRunRecord{}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokens: map[string]string{"valid-token": "reports"},
|
||||||
|
limits: map[string]int64{"reports": 4},
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, tt.url, tt.body)
|
||||||
|
request.Header.Set("Authorization", "Bearer valid-token")
|
||||||
|
request.Header.Set("Content-Type", tt.contentType)
|
||||||
|
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != tt.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, tt.wantStatus, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if reader, ok := tt.body.(*countingReader); ok && reader.reads != 0 {
|
||||||
|
t.Fatalf("full queue read body %d time(s), want zero", reader.reads)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadHTTPHandlerRunStatusAndHealth(t *testing.T) {
|
||||||
|
finishedAt := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
handler := uploadHTTPHandler{
|
||||||
|
coordinator: fakeUploadCoordinator{
|
||||||
|
canAccept: true,
|
||||||
|
status: func(runID UploadRunID) (UploadRunRecord, bool) {
|
||||||
|
if runID != "reports.20260603T120000Z.abcdef12" {
|
||||||
|
return UploadRunRecord{}, false
|
||||||
|
}
|
||||||
|
return UploadRunRecord{
|
||||||
|
ID: runID,
|
||||||
|
PipelineID: "reports",
|
||||||
|
Status: UploadStatusSucceeded,
|
||||||
|
FinishedAt: &finishedAt,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokens: map[string]string{"valid-token": "reports"},
|
||||||
|
limits: map[string]int64{"reports": 1024},
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("health status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/runs/reports.20260603T120000Z.abcdef12", nil))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("run status = %d, want %d; body = %q", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||||
|
}
|
||||||
|
var record UploadRunRecord
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &record); err != nil {
|
||||||
|
t.Fatalf("decode run status: %v", err)
|
||||||
|
}
|
||||||
|
if record.ID != "reports.20260603T120000Z.abcdef12" || record.Status != UploadStatusSucceeded {
|
||||||
|
t.Fatalf("record = %#v, want succeeded run status", record)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/runs/unknown", nil))
|
||||||
|
if recorder.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown run status = %d, want %d", recorder.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
reads int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *countingReader) Read(data []byte) (int, error) {
|
||||||
|
reader.reads++
|
||||||
|
return reader.reader.Read(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadHTTPTestConfig() config.Config {
|
||||||
|
size := config.ByteSize(1024)
|
||||||
|
retention := config.Duration(24 * time.Hour)
|
||||||
|
cfg := config.Config{
|
||||||
|
Server: config.Server{HTTP: config.HTTPServer{
|
||||||
|
Bind: config.DefaultHTTPBind,
|
||||||
|
StagingRoot: "/tmp/distributor-test",
|
||||||
|
MaxUploadSize: &size,
|
||||||
|
QueueSize: 2,
|
||||||
|
MaxConcurrency: 1,
|
||||||
|
Retention: &retention,
|
||||||
|
}},
|
||||||
|
Pipelines: []config.Pipeline{{
|
||||||
|
ID: "reports",
|
||||||
|
Source: config.Backend{
|
||||||
|
Backend: config.BackendHTTPUpload,
|
||||||
|
Upload: config.HTTPUpload{
|
||||||
|
TokenEnv: "UPLOAD_TOKEN",
|
||||||
|
StagingPath: "/tmp/distributor-test/reports",
|
||||||
|
MaxUploadSize: &size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destinations: []config.Destination{{
|
||||||
|
ID: "local",
|
||||||
|
Backend: config.BackendLocal,
|
||||||
|
Path: "/tmp/distributor-output",
|
||||||
|
Publish: &config.PublishPolicy{Source: true},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
config.ApplyDefaults(&cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadHTTPTestEnvironment(values map[string]string) config.Environment {
|
||||||
|
return config.NewEnvironment(values, func(string) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
return versionCommand(ctx, args[1:], stdout, stderr)
|
return versionCommand(ctx, args[1:], stdout, stderr)
|
||||||
case "run":
|
case "run":
|
||||||
return runCommand(ctx, args[1:], stdout, stderr)
|
return runCommand(ctx, args[1:], stdout, stderr)
|
||||||
|
case "serve":
|
||||||
|
return serveCommand(ctx, args[1:], stdout, stderr)
|
||||||
case "validate":
|
case "validate":
|
||||||
return validateCommand(ctx, args[1:], stdout, stderr)
|
return validateCommand(ctx, args[1:], stdout, stderr)
|
||||||
case "inspect":
|
case "inspect":
|
||||||
@@ -51,6 +53,7 @@ Usage:
|
|||||||
Commands:
|
Commands:
|
||||||
version Print version information
|
version Print version information
|
||||||
run Run configured distribution pipelines
|
run Run configured distribution pipelines
|
||||||
|
serve Run the HTTP upload server
|
||||||
validate Validate a source bundle or bundle tree
|
validate Validate a source bundle or bundle tree
|
||||||
inspect Inspect bundles or distributor state
|
inspect Inspect bundles or distributor state
|
||||||
manifest Create source bundle manifests
|
manifest Create source bundle manifests
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||||
producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
producerbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
@@ -92,6 +93,34 @@ func TestExecuteVersionJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteServeParsesConfig(t *testing.T) {
|
||||||
|
originalServeApp := serveApp
|
||||||
|
defer func() {
|
||||||
|
serveApp = originalServeApp
|
||||||
|
}()
|
||||||
|
var gotOptions app.ServeOptions
|
||||||
|
serveApp = func(_ context.Context, options app.ServeOptions) error {
|
||||||
|
gotOptions = options
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"serve", "--config", "config.yml"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitOK {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||||
|
}
|
||||||
|
if gotOptions.ConfigPath != "config.yml" {
|
||||||
|
t.Fatalf("ConfigPath = %q, want config.yml", gotOptions.ConfigPath)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRejectsInvalidFormat(t *testing.T) {
|
func TestExecuteRejectsInvalidFormat(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
|||||||
46
internal/cli/serve.go
Normal file
46
internal/cli/serve.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
var serveApp = app.Serve
|
||||||
|
|
||||||
|
func serveCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
if hasHelp(args) {
|
||||||
|
printServeHelp(stdout)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
configPath := flags.String("config", "", "path to config file")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if rejectPositionalArgs(stderr, "serve", flags.Args()) {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := serveApp(ctx, app.ServeOptions{ConfigPath: *configPath}); err != nil {
|
||||||
|
return fail(stderr, err)
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func printServeHelp(w io.Writer) {
|
||||||
|
fmt.Fprint(w, `Usage:
|
||||||
|
distributor serve --config <path>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config <path> Path to config file
|
||||||
|
|
||||||
|
Serve loads configured HTTP upload sources, resolves upload tokens through the
|
||||||
|
configured secret environment, and starts the HTTP upload API.
|
||||||
|
`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user