Add JSON output format for CLI commands
This commit is contained in:
39
docs/cli.md
39
docs/cli.md
@@ -12,10 +12,10 @@ This discovers the example source bundle and publishes source files to `workspac
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
distributor [--help]
|
distributor [--help]
|
||||||
distributor version
|
distributor version [--format text|json]
|
||||||
distributor run [--config <path>] [--dry-run] [--force]
|
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
||||||
distributor validate <path>
|
distributor validate [--format text|json] <path>
|
||||||
distributor inspect <path>
|
distributor inspect [--format text|json] <path>
|
||||||
```
|
```
|
||||||
|
|
||||||
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
- `version`: prints the application name and version. Development builds print `distributor dev`.
|
||||||
@@ -35,6 +35,10 @@ All subcommands:
|
|||||||
|
|
||||||
- `--help`, `-h`: print command-specific help.
|
- `--help`, `-h`: print command-specific help.
|
||||||
|
|
||||||
|
Output-producing subcommands:
|
||||||
|
|
||||||
|
- `--format text|json`: output format. `text` is the default. Help and usage output are always text.
|
||||||
|
|
||||||
`run` flags:
|
`run` flags:
|
||||||
|
|
||||||
- `--config <path>`: config file to load. If omitted, `run` uses `/usr/local/etc/distributor/config.yml`.
|
- `--config <path>`: config file to load. If omitted, `run` uses `/usr/local/etc/distributor/config.yml`.
|
||||||
@@ -89,7 +93,9 @@ go run ./cmd/distributor run --config <config-path> --dry-run --force
|
|||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
`run` prints the number of configured pipelines, one line per pipeline, one line per planned destination action, and a final status line. Destination action lines include the bundle path, destination id, destination backend, action, outputs, and reason. Actions include:
|
Text output is the default and is intended for humans.
|
||||||
|
|
||||||
|
`run` text output prints the number of configured pipelines, one line per pipeline, one line per planned destination action, and a final status line. Destination action lines include the bundle path, destination id, destination backend, action, outputs, and reason. Actions include:
|
||||||
|
|
||||||
- `publish_new`: destination has no managed state and is empty.
|
- `publish_new`: destination has no managed state and is empty.
|
||||||
- `replace_older`: destination state is older than the source manifest.
|
- `replace_older`: destination state is older than the source manifest.
|
||||||
@@ -102,6 +108,29 @@ The command exits non-zero if any destination fails. Independent later destinati
|
|||||||
|
|
||||||
The final status line includes counters for `publish_new`, `replace_older`, `force_replace`, skipped destinations, failures, and whether the run was a dry run.
|
The final status line includes counters for `publish_new`, `replace_older`, `force_replace`, skipped destinations, failures, and whether the run was a dry run.
|
||||||
|
|
||||||
|
JSON output writes exactly one JSON document to stdout:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"command": "inspect",
|
||||||
|
"ok": true,
|
||||||
|
"warnings": [],
|
||||||
|
"result": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Warnings are objects in the top-level `warnings` array and are not printed again as text. Fatal setup errors, such as a missing config file or invalid arguments, write no JSON document and return a non-zero exit code with a text error on stderr.
|
||||||
|
|
||||||
|
`run --format json` returns partial results when destination failures occur after planning or execution begins. In that case stdout contains `ok: false`, a `result` with pipeline summaries, destination actions, final counters, and a top-level `errors` array; the process still exits non-zero.
|
||||||
|
|
||||||
|
Command-specific JSON results:
|
||||||
|
|
||||||
|
- `version`: application name and version.
|
||||||
|
- `validate`: bundle count and discovered bundle identifiers.
|
||||||
|
- `inspect`: bundle path, id, created timestamp, digest, file count, total size, and manifest file records.
|
||||||
|
- `run`: dry-run status, pipeline summaries, destination action records, output records, final counters, warnings, and partial failure records.
|
||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
Use `validate` before publication when a producer has written a new bundle. Use `inspect` to confirm normalized ids, timestamps, digests, file paths, and file sizes.
|
Use `validate` before publication when a producer has written a new bundle. Use `inspect` to confirm normalized ids, timestamps, digests, file paths, and file sizes.
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ Preview local fan-out publication:
|
|||||||
go run ./cmd/distributor run --config examples/fan-out.yml --dry-run
|
go run ./cmd/distributor run --config examples/fan-out.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:
|
Preview an environment-gated SSH destination config after editing it for an SSH/SFTP endpoint you control:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -78,6 +84,8 @@ Dry-run output is useful before publishing to confirm actions such as `publish_n
|
|||||||
|
|
||||||
Destination action lines include the destination backend, so mixed local, SSH, and S3 fan-out runs can be audited before publication.
|
Destination action lines include the destination backend, so mixed local, SSH, and S3 fan-out runs can be audited before publication.
|
||||||
|
|
||||||
|
Use `--format json` when another process needs stable run data. JSON output includes warnings, pipeline summaries, destination actions, output records, final counters, and partial failure records. 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
|
## Retry and Replacement Behavior
|
||||||
|
|
||||||
If a destination has matching `.distributor.json`, publication skips it as already published.
|
If a destination has matching `.distributor.json`, publication skips it as already published.
|
||||||
@@ -107,6 +115,8 @@ If one destination fails in a fan-out run, independent later destinations are st
|
|||||||
|
|
||||||
Errors include the pipeline id, destination id, destination backend, and bundle path where applicable.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -36,6 +36,42 @@ 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 executable workflows.
|
||||||
|
|
||||||
|
## `--format: format must be text or json`
|
||||||
|
|
||||||
|
Likely cause: a command was run with an unsupported output format.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor run --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: use `--format text` or `--format json`. Help and usage output are always text.
|
||||||
|
|
||||||
|
## `--format json` wrote no JSON output
|
||||||
|
|
||||||
|
Likely cause: the command failed before it could construct a result, such as a missing config file, invalid arguments, unreadable secrets directory, or source setup failure.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor run --config <config-path> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: read the stderr error and fix the setup problem. JSON mode writes a document only after the command has enough information to construct a result.
|
||||||
|
|
||||||
|
## `--format json` exited non-zero with `ok: false`
|
||||||
|
|
||||||
|
Likely cause: `run` began planning or executing destinations, and at least one destination failed while other destination results were still available.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/distributor run --config <config-path> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix: inspect the top-level `errors` array, `result.actions`, and `result.summary`. Fix the failed destination, then preview with `--dry-run --format json` before retrying.
|
||||||
|
|
||||||
## `prefix must be a clean relative slash-separated path`
|
## `prefix must be a clean relative slash-separated path`
|
||||||
|
|
||||||
Likely cause: S3 `prefix` contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed.
|
Likely cause: S3 `prefix` contains traversal, dot segments, empty segments, or backslashes after leading and trailing slashes are trimmed.
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import (
|
|||||||
type InspectOptions struct {
|
type InspectOptions struct {
|
||||||
Path string
|
Path string
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
|
OutputFormat OutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
func Inspect(ctx context.Context, options InspectOptions) error {
|
func Inspect(ctx context.Context, options InspectOptions) error {
|
||||||
|
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if options.Path == "" {
|
if options.Path == "" {
|
||||||
return fmt.Errorf("inspect command requires a path")
|
return fmt.Errorf("inspect command requires a path")
|
||||||
}
|
}
|
||||||
@@ -26,9 +30,60 @@ func Inspect(ctx context.Context, options InspectOptions) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if IsJSONOutput(options.OutputFormat) {
|
||||||
|
return WriteJSONEnvelope(options.Stdout, "inspect", true, nil, inspectResultFromBundles(bundles), nil)
|
||||||
|
}
|
||||||
return writeInspection(options.Stdout, bundles)
|
return writeInspection(options.Stdout, bundles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type inspectResult struct {
|
||||||
|
BundleCount int `json:"bundle_count"`
|
||||||
|
Bundles []inspectBundleResult `json:"bundles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectBundleResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Created string `json:"created"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
Files []inspectFileResult `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectFileResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectResultFromBundles(bundles []bundle.Bundle) inspectResult {
|
||||||
|
result := inspectResult{
|
||||||
|
BundleCount: len(bundles),
|
||||||
|
Bundles: make([]inspectBundleResult, 0, len(bundles)),
|
||||||
|
}
|
||||||
|
for _, sourceBundle := range bundles {
|
||||||
|
bundleResult := inspectBundleResult{
|
||||||
|
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
|
||||||
|
ID: sourceBundle.Manifest.ID,
|
||||||
|
Created: sourceBundle.Manifest.Created.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
Digest: sourceBundle.Manifest.Digest,
|
||||||
|
FileCount: len(sourceBundle.Manifest.Files),
|
||||||
|
Files: make([]inspectFileResult, 0, len(sourceBundle.Manifest.Files)),
|
||||||
|
}
|
||||||
|
for _, file := range sourceBundle.Manifest.Files {
|
||||||
|
bundleResult.TotalSize += file.Size
|
||||||
|
bundleResult.Files = append(bundleResult.Files, inspectFileResult{
|
||||||
|
Path: file.Path,
|
||||||
|
SHA256: file.SHA256,
|
||||||
|
Size: file.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result.Bundles = append(result.Bundles, bundleResult)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func writeInspection(w io.Writer, bundles []bundle.Bundle) error {
|
func writeInspection(w io.Writer, bundles []bundle.Bundle) error {
|
||||||
if w == nil {
|
if w == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
76
internal/app/output.go
Normal file
76
internal/app/output.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const outputSchemaVersion = 1
|
||||||
|
|
||||||
|
type OutputFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutputFormatText OutputFormat = "text"
|
||||||
|
OutputFormatJSON OutputFormat = "json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutputWarning struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputError struct {
|
||||||
|
PipelineID string `json:"pipeline_id,omitempty"`
|
||||||
|
DestinationID string `json:"destination_id,omitempty"`
|
||||||
|
Backend string `json:"backend,omitempty"`
|
||||||
|
BundlePath string `json:"bundle_path,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type outputEnvelope struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Warnings []OutputWarning `json:"warnings"`
|
||||||
|
Result any `json:"result"`
|
||||||
|
Errors []OutputError `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeOutputFormat(format OutputFormat) OutputFormat {
|
||||||
|
if format == "" {
|
||||||
|
return OutputFormatText
|
||||||
|
}
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateOutputFormat(format OutputFormat) error {
|
||||||
|
switch NormalizeOutputFormat(format) {
|
||||||
|
case OutputFormatText, OutputFormatJSON:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("format must be text or json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsJSONOutput(format OutputFormat) bool {
|
||||||
|
return NormalizeOutputFormat(format) == OutputFormatJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteJSONEnvelope(w io.Writer, command string, ok bool, warnings []OutputWarning, result any, errors []OutputError) error {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if warnings == nil {
|
||||||
|
warnings = []OutputWarning{}
|
||||||
|
}
|
||||||
|
envelope := outputEnvelope{
|
||||||
|
SchemaVersion: outputSchemaVersion,
|
||||||
|
Command: command,
|
||||||
|
OK: ok,
|
||||||
|
Warnings: warnings,
|
||||||
|
Result: result,
|
||||||
|
Errors: errors,
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
return encoder.Encode(envelope)
|
||||||
|
}
|
||||||
@@ -19,10 +19,14 @@ type RunOptions struct {
|
|||||||
DryRun bool
|
DryRun bool
|
||||||
Force bool
|
Force bool
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
|
OutputFormat OutputFormat
|
||||||
Notifier notify.Notifier
|
Notifier notify.Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, options RunOptions) error {
|
func Run(ctx context.Context, options RunOptions) error {
|
||||||
|
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -49,28 +53,41 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
|
|||||||
if notifier == nil {
|
if notifier == nil {
|
||||||
notifier = notify.Noop{}
|
notifier = notify.Noop{}
|
||||||
}
|
}
|
||||||
|
jsonOutput := IsJSONOutput(options.OutputFormat)
|
||||||
summary := runSummary{dryRun: options.DryRun}
|
summary := runSummary{dryRun: options.DryRun}
|
||||||
|
result := runResult{
|
||||||
|
DryRun: options.DryRun,
|
||||||
|
Pipelines: []runPipelineResult{},
|
||||||
|
Actions: []runActionResult{},
|
||||||
|
}
|
||||||
|
var warnings []OutputWarning
|
||||||
var failures runFailures
|
var failures runFailures
|
||||||
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if options.Stdout != nil {
|
secretWarnings := secretConflictWarnings(secretLoad.Conflicts)
|
||||||
if err := writeSecretConflictWarnings(options.Stdout, secretLoad.Conflicts); err != nil {
|
if jsonOutput {
|
||||||
|
warnings = append(warnings, secretWarnings...)
|
||||||
|
} else if options.Stdout != nil {
|
||||||
|
if err := writeWarnings(options.Stdout, secretWarnings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
backends := provider(secretLoad.Environment)
|
backends := provider(secretLoad.Environment)
|
||||||
backends.readOnlyKnownHosts = options.DryRun
|
backends.readOnlyKnownHosts = options.DryRun
|
||||||
transforms := newTransformRegistry()
|
transforms := newTransformRegistry()
|
||||||
if options.Stdout != nil {
|
if options.Stdout != nil && !jsonOutput {
|
||||||
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
|
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, pipeline := range cfg.Pipelines {
|
for _, pipeline := range cfg.Pipelines {
|
||||||
if options.Stdout != nil {
|
pipelineWarnings := sshWarnings(pipeline)
|
||||||
if err := writeSSHWarnings(options.Stdout, pipeline); err != nil {
|
if jsonOutput {
|
||||||
|
warnings = append(warnings, pipelineWarnings...)
|
||||||
|
} else if options.Stdout != nil {
|
||||||
|
if err := writeWarnings(options.Stdout, pipelineWarnings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +100,13 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
|
|||||||
closeBackend(sourceBackend)
|
closeBackend(sourceBackend)
|
||||||
return fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
|
return fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
|
||||||
}
|
}
|
||||||
if options.Stdout != nil {
|
result.Pipelines = append(result.Pipelines, runPipelineResult{
|
||||||
|
ID: pipeline.ID,
|
||||||
|
SourceBackend: pipeline.Source.Backend,
|
||||||
|
BundleCount: len(bundles),
|
||||||
|
Destinations: destinationIDs(pipeline.Destinations),
|
||||||
|
})
|
||||||
|
if options.Stdout != nil && !jsonOutput {
|
||||||
if _, err := fmt.Fprintf(options.Stdout, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.Source.Backend, len(bundles), destinationSummary(pipeline.Destinations)); err != nil {
|
if _, err := fmt.Fprintf(options.Stdout, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.Source.Backend, len(bundles), destinationSummary(pipeline.Destinations)); err != nil {
|
||||||
closeBackend(sourceBackend)
|
closeBackend(sourceBackend)
|
||||||
return err
|
return err
|
||||||
@@ -95,7 +118,9 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
|
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
|
||||||
summary.recordFailure()
|
summary.recordFailure()
|
||||||
if options.Stdout != nil {
|
if jsonOutput {
|
||||||
|
result.Actions = append(result.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, sourceBundle.RootRelativePath, err))
|
||||||
|
} else if options.Stdout != nil {
|
||||||
writeErrorLine(options.Stdout, sourceBundle.RootRelativePath, destination.ID, destination.Backend, err)
|
writeErrorLine(options.Stdout, sourceBundle.RootRelativePath, destination.ID, destination.Backend, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -122,10 +147,23 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
|
|||||||
Force: options.Force,
|
Force: options.Force,
|
||||||
}
|
}
|
||||||
plan, err := publish.Build(ctx, req)
|
plan, err := publish.Build(ctx, req)
|
||||||
if err != nil && plan.DestinationID == "" {
|
if err != nil {
|
||||||
plan = publish.Plan{DestinationID: destination.ID, BundlePath: sourceBundle.RootRelativePath}
|
if plan.PipelineID == "" {
|
||||||
|
plan.PipelineID = pipeline.ID
|
||||||
}
|
}
|
||||||
if options.Stdout != nil {
|
if plan.DestinationID == "" {
|
||||||
|
plan.DestinationID = destination.ID
|
||||||
|
}
|
||||||
|
if plan.BundleID == "" {
|
||||||
|
plan.BundleID = sourceBundle.Manifest.ID
|
||||||
|
}
|
||||||
|
if plan.BundlePath == "" {
|
||||||
|
plan.BundlePath = sourceBundle.RootRelativePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
result.Actions = append(result.Actions, runActionFromPlan(destination.Backend, plan, err))
|
||||||
|
} else if options.Stdout != nil {
|
||||||
writePlanLine(options.Stdout, destination.Backend, plan, err)
|
writePlanLine(options.Stdout, destination.Backend, plan, err)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -156,7 +194,12 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
|
|||||||
}
|
}
|
||||||
closeBackend(sourceBackend)
|
closeBackend(sourceBackend)
|
||||||
}
|
}
|
||||||
if options.Stdout != nil {
|
result.Summary = summary.Result()
|
||||||
|
if jsonOutput {
|
||||||
|
if err := WriteJSONEnvelope(options.Stdout, "run", len(failures.items) == 0, warnings, result, failures.outputErrors()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if options.Stdout != nil {
|
||||||
if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil {
|
if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -212,6 +255,14 @@ func outputSummary(outputs []publish.Output) string {
|
|||||||
return strings.Join(paths, ",")
|
return strings.Join(paths, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func destinationIDs(destinations []config.Destination) []string {
|
||||||
|
ids := make([]string, 0, len(destinations))
|
||||||
|
for _, destination := range destinations {
|
||||||
|
ids = append(ids, destination.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func destinationSummary(destinations []config.Destination) string {
|
func destinationSummary(destinations []config.Destination) string {
|
||||||
if len(destinations) == 0 {
|
if len(destinations) == 0 {
|
||||||
return "none"
|
return "none"
|
||||||
@@ -224,26 +275,45 @@ func destinationSummary(destinations []config.Destination) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeSecretConflictWarnings(w io.Writer, conflicts []config.SecretConflict) error {
|
func writeSecretConflictWarnings(w io.Writer, conflicts []config.SecretConflict) error {
|
||||||
|
return writeWarnings(w, secretConflictWarnings(conflicts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretConflictWarnings(conflicts []config.SecretConflict) []OutputWarning {
|
||||||
|
warnings := make([]OutputWarning, 0, len(conflicts))
|
||||||
for _, conflict := range conflicts {
|
for _, conflict := range conflicts {
|
||||||
if _, err := fmt.Fprintf(w, "Warning: secret %s ignored because the real environment already has that variable\n", conflict.Name); err != nil {
|
warnings = append(warnings, OutputWarning{
|
||||||
return err
|
Message: fmt.Sprintf("secret %s ignored because the real environment already has that variable", conflict.Name),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
return warnings
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSSHWarnings(w io.Writer, pipeline config.Pipeline) error {
|
func writeSSHWarnings(w io.Writer, pipeline config.Pipeline) error {
|
||||||
|
return writeWarnings(w, sshWarnings(pipeline))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshWarnings(pipeline config.Pipeline) []OutputWarning {
|
||||||
|
var warnings []OutputWarning
|
||||||
if pipeline.Source.Backend == config.BackendSSH && pipeline.Source.SSH.HostKeyPolicy == config.HostKeyPolicyOff {
|
if pipeline.Source.Backend == config.BackendSSH && pipeline.Source.SSH.HostKeyPolicy == config.HostKeyPolicyOff {
|
||||||
if _, err := fmt.Fprintf(w, "Warning: pipeline=%s source host_key_policy=off disables SSH host key checking\n", pipeline.ID); err != nil {
|
warnings = append(warnings, OutputWarning{
|
||||||
return err
|
Message: fmt.Sprintf("pipeline=%s source host_key_policy=off disables SSH host key checking", pipeline.ID),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
for _, destination := range pipeline.Destinations {
|
for _, destination := range pipeline.Destinations {
|
||||||
if destination.Backend == config.BackendSSH && destination.SSH.HostKeyPolicy == config.HostKeyPolicyOff {
|
if destination.Backend == config.BackendSSH && destination.SSH.HostKeyPolicy == config.HostKeyPolicyOff {
|
||||||
if _, err := fmt.Fprintf(w, "Warning: pipeline=%s destination=%s host_key_policy=off disables SSH host key checking\n", pipeline.ID, destination.ID); err != nil {
|
warnings = append(warnings, OutputWarning{
|
||||||
return err
|
Message: fmt.Sprintf("pipeline=%s destination=%s host_key_policy=off disables SSH host key checking", pipeline.ID, destination.ID),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWarnings(w io.Writer, warnings []OutputWarning) error {
|
||||||
|
for _, warning := range warnings {
|
||||||
|
if _, err := fmt.Fprintf(w, "Warning: %s\n", warning.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -274,6 +344,96 @@ func notifyEvent(plan publish.Plan) notify.Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type runResult struct {
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
Pipelines []runPipelineResult `json:"pipelines"`
|
||||||
|
Actions []runActionResult `json:"actions"`
|
||||||
|
Summary runSummaryResult `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runPipelineResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SourceBackend string `json:"source_backend"`
|
||||||
|
BundleCount int `json:"bundle_count"`
|
||||||
|
Destinations []string `json:"destinations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runActionResult struct {
|
||||||
|
PipelineID string `json:"pipeline_id,omitempty"`
|
||||||
|
DestinationID string `json:"destination_id"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
BundleID string `json:"bundle_id,omitempty"`
|
||||||
|
BundlePath string `json:"bundle_path"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Outputs []runOutputResult `json:"outputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runOutputResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
SourcePath string `json:"source_path,omitempty"`
|
||||||
|
Transform string `json:"transform,omitempty"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActionResult {
|
||||||
|
if planErr != nil {
|
||||||
|
destinationID := plan.DestinationID
|
||||||
|
if destinationID == "" {
|
||||||
|
destinationID = "unknown"
|
||||||
|
}
|
||||||
|
return runActionResult{
|
||||||
|
PipelineID: plan.PipelineID,
|
||||||
|
DestinationID: destinationID,
|
||||||
|
Backend: backend,
|
||||||
|
BundleID: plan.BundleID,
|
||||||
|
BundlePath: storage.DisplayPath(plan.BundlePath),
|
||||||
|
Action: "error",
|
||||||
|
Reason: planErr.Error(),
|
||||||
|
Outputs: []runOutputResult{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runActionResult{
|
||||||
|
PipelineID: plan.PipelineID,
|
||||||
|
DestinationID: plan.DestinationID,
|
||||||
|
Backend: backend,
|
||||||
|
BundleID: plan.BundleID,
|
||||||
|
BundlePath: storage.DisplayPath(plan.BundlePath),
|
||||||
|
Action: string(plan.Action),
|
||||||
|
Reason: plan.Reason,
|
||||||
|
Outputs: runOutputsFromPlan(plan.Outputs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) runActionResult {
|
||||||
|
return runActionResult{
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
DestinationID: destinationID,
|
||||||
|
Backend: backend,
|
||||||
|
BundlePath: storage.DisplayPath(bundlePath),
|
||||||
|
Action: "error",
|
||||||
|
Reason: err.Error(),
|
||||||
|
Outputs: []runOutputResult{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOutputsFromPlan(outputs []publish.Output) []runOutputResult {
|
||||||
|
results := make([]runOutputResult, 0, len(outputs))
|
||||||
|
for _, output := range outputs {
|
||||||
|
results = append(results, runOutputResult{
|
||||||
|
Path: output.DestinationPath,
|
||||||
|
Kind: output.Kind,
|
||||||
|
SourcePath: output.SourcePath,
|
||||||
|
Transform: output.Transform,
|
||||||
|
SHA256: output.SHA256,
|
||||||
|
Size: output.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
type runSummary struct {
|
type runSummary struct {
|
||||||
dryRun bool
|
dryRun bool
|
||||||
planned int
|
planned int
|
||||||
@@ -310,6 +470,34 @@ func (s runSummary) 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", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun)
|
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type runSummaryResult struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Planned int `json:"planned"`
|
||||||
|
PublishNew int `json:"publish_new"`
|
||||||
|
ReplaceOlder int `json:"replace_older"`
|
||||||
|
ForceReplace int `json:"force_replace"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s runSummary) Result() runSummaryResult {
|
||||||
|
status := "ok"
|
||||||
|
if s.failures > 0 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
return runSummaryResult{
|
||||||
|
Status: status,
|
||||||
|
Planned: s.planned,
|
||||||
|
PublishNew: s.publishNew,
|
||||||
|
ReplaceOlder: s.replaceOlder,
|
||||||
|
ForceReplace: s.forceReplace,
|
||||||
|
Skipped: s.skipped,
|
||||||
|
Failed: s.failures,
|
||||||
|
DryRun: s.dryRun,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type runFailure struct {
|
type runFailure struct {
|
||||||
pipelineID string
|
pipelineID string
|
||||||
destinationID string
|
destinationID string
|
||||||
@@ -343,6 +531,28 @@ func (f runFailures) Error() string {
|
|||||||
return "run failed: " + strings.Join(parts, "; ")
|
return "run failed: " + strings.Join(parts, "; ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f runFailures) outputErrors() []OutputError {
|
||||||
|
if len(f.items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
errors := make([]OutputError, 0, len(f.items))
|
||||||
|
for _, item := range f.items {
|
||||||
|
errors = append(errors, OutputError{
|
||||||
|
PipelineID: item.pipelineID,
|
||||||
|
DestinationID: item.destinationID,
|
||||||
|
Backend: item.backend,
|
||||||
|
BundlePath: item.bundlePath,
|
||||||
|
Message: item.err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPartialResultError(err error) bool {
|
||||||
|
var failures runFailures
|
||||||
|
return errors.As(err, &failures)
|
||||||
|
}
|
||||||
|
|
||||||
func (f runFailures) Unwrap() error {
|
func (f runFailures) Unwrap() error {
|
||||||
errs := make([]error, 0, len(f.items))
|
errs := make([]error, 0, len(f.items))
|
||||||
for _, item := range f.items {
|
for _, item := range f.items {
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ValidateOptions struct {
|
type ValidateOptions struct {
|
||||||
Path string
|
Path string
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
|
OutputFormat OutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(ctx context.Context, options ValidateOptions) error {
|
func Validate(ctx context.Context, options ValidateOptions) error {
|
||||||
|
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if options.Path == "" {
|
if options.Path == "" {
|
||||||
return fmt.Errorf("validate command requires a path")
|
return fmt.Errorf("validate command requires a path")
|
||||||
}
|
}
|
||||||
@@ -25,8 +30,35 @@ func Validate(ctx context.Context, options ValidateOptions) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if IsJSONOutput(options.OutputFormat) {
|
||||||
|
return WriteJSONEnvelope(options.Stdout, "validate", true, nil, validateResultFromBundles(bundles), nil)
|
||||||
|
}
|
||||||
if options.Stdout != nil {
|
if options.Stdout != nil {
|
||||||
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s)\n", len(bundles))
|
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s)\n", len(bundles))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type validateResult struct {
|
||||||
|
BundleCount int `json:"bundle_count"`
|
||||||
|
Bundles []validateBundleResult `json:"bundles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validateBundleResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResultFromBundles(bundles []bundle.Bundle) validateResult {
|
||||||
|
result := validateResult{
|
||||||
|
BundleCount: len(bundles),
|
||||||
|
Bundles: make([]validateBundleResult, 0, len(bundles)),
|
||||||
|
}
|
||||||
|
for _, sourceBundle := range bundles {
|
||||||
|
result.Bundles = append(result.Bundles, validateBundleResult{
|
||||||
|
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
|
||||||
|
ID: sourceBundle.Manifest.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
22
internal/cli/format.go
Normal file
22
internal/cli/format.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addFormatFlag(flags *flag.FlagSet) *string {
|
||||||
|
return flags.String("format", string(app.OutputFormatText), "output format: text or json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOutputFormat(stderr io.Writer, command, raw string) (app.OutputFormat, bool) {
|
||||||
|
format := app.OutputFormat(raw)
|
||||||
|
if err := app.ValidateOutputFormat(format); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "%s: %s --format: %s\n", app.Name, command, err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return app.NormalizeOutputFormat(format), true
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -13,11 +14,21 @@ func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer
|
|||||||
printInspectHelp(stdout)
|
printInspectHelp(stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
path, ok := parseOptionalPathArg(stderr, "inspect", args)
|
flags := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
formatFlag := addFormatFlag(flags)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
format, ok := parseOutputFormat(stderr, "inspect", *formatFlag)
|
||||||
if !ok {
|
if !ok {
|
||||||
return exitUsage
|
return exitUsage
|
||||||
}
|
}
|
||||||
if err := app.Inspect(ctx, app.InspectOptions{Path: path, Stdout: stdout}); err != nil {
|
path, ok := parseOptionalPathArg(stderr, "inspect", flags.Args())
|
||||||
|
if !ok {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if err := app.Inspect(ctx, app.InspectOptions{Path: path, Stdout: stdout, OutputFormat: format}); err != nil {
|
||||||
return fail(stderr, err)
|
return fail(stderr, err)
|
||||||
}
|
}
|
||||||
return exitOK
|
return exitOK
|
||||||
@@ -25,7 +36,10 @@ func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer
|
|||||||
|
|
||||||
func printInspectHelp(w io.Writer) {
|
func printInspectHelp(w io.Writer) {
|
||||||
fmt.Fprint(w, `Usage:
|
fmt.Fprint(w, `Usage:
|
||||||
distributor inspect <path>
|
distributor inspect [--format text|json] <path>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--format text|json Output format
|
||||||
|
|
||||||
Print a normalized summary of local source bundles.
|
Print a normalized summary of local source bundles.
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +15,29 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||||
|
var envelope map[string]any
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
t.Fatalf("decode JSON envelope: %v; stdout = %q", err, stdout.String())
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != io.EOF {
|
||||||
|
t.Fatalf("stdout contains more than one JSON document: %q", stdout.String())
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
func envelopeResult(t *testing.T, envelope map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
result, ok := envelope["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("result = %#v, want object", envelope["result"])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRootHelp(t *testing.T) {
|
func TestExecuteRootHelp(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
@@ -44,6 +70,43 @@ func TestExecuteVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteVersionJSON(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"version", "--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"] != "version" || envelope["ok"] != true {
|
||||||
|
t.Fatalf("envelope = %#v, want version ok", envelope)
|
||||||
|
}
|
||||||
|
result := envelopeResult(t, envelope)
|
||||||
|
if result["application"] != "distributor" || result["version"] != "dev" {
|
||||||
|
t.Fatalf("result = %#v, want application/version", result)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRejectsInvalidFormat(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"version", "--format", "xml"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitUsage {
|
||||||
|
t.Fatalf("exit code = %d, want %d", code, exitUsage)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "format must be text or json") {
|
||||||
|
t.Fatalf("stderr = %q, want invalid format error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteValidate(t *testing.T) {
|
func TestExecuteValidate(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
@@ -57,6 +120,24 @@ func TestExecuteValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteValidateJSON(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"validate", "--format", "json", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &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"] != "validate" || envelope["ok"] != true {
|
||||||
|
t.Fatalf("envelope = %#v, want validate ok", envelope)
|
||||||
|
}
|
||||||
|
result := envelopeResult(t, envelope)
|
||||||
|
if result["bundle_count"] != float64(1) {
|
||||||
|
t.Fatalf("result = %#v, want one bundle", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteValidateArgs(t *testing.T) {
|
func TestExecuteValidateArgs(t *testing.T) {
|
||||||
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -115,6 +196,32 @@ func TestExecuteInspect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteInspectJSON(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"inspect", "--format", "json", filepath.Join("..", "bundle", "testdata", "valid_bundle")}, &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"] != "inspect" || envelope["ok"] != true {
|
||||||
|
t.Fatalf("envelope = %#v, want inspect ok", envelope)
|
||||||
|
}
|
||||||
|
result := envelopeResult(t, envelope)
|
||||||
|
bundles, ok := result["bundles"].([]any)
|
||||||
|
if !ok || len(bundles) != 1 {
|
||||||
|
t.Fatalf("bundles = %#v, want one bundle", result["bundles"])
|
||||||
|
}
|
||||||
|
bundle, ok := bundles[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("bundle = %#v, want object", bundles[0])
|
||||||
|
}
|
||||||
|
if bundle["id"] != "weather.daily.brentwood.2026-05-30" || bundle["file_count"] != float64(2) || bundle["total_size"] != float64(24) {
|
||||||
|
t.Fatalf("bundle = %#v, want normalized metadata", bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteInspectArgs(t *testing.T) {
|
func TestExecuteInspectArgs(t *testing.T) {
|
||||||
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -180,6 +287,162 @@ func TestExecuteRunDryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunJSONDryRun(t *testing.T) {
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||||
|
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, t.TempDir())
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"run", "--config", configPath, "--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"] != "run" || envelope["ok"] != true {
|
||||||
|
t.Fatalf("envelope = %#v, want run ok", envelope)
|
||||||
|
}
|
||||||
|
result := envelopeResult(t, envelope)
|
||||||
|
if result["dry_run"] != true {
|
||||||
|
t.Fatalf("result = %#v, want dry_run true", result)
|
||||||
|
}
|
||||||
|
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"] != "publish_new" {
|
||||||
|
t.Fatalf("action = %#v, want publish_new", actions[0])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunJSONWarningsAreStructured(t *testing.T) {
|
||||||
|
name := "DISTRIBUTOR_TEST_CLI_JSON_SECRET"
|
||||||
|
t.Setenv(name, "process-value")
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
destinationRoot := t.TempDir()
|
||||||
|
secretsRoot := t.TempDir()
|
||||||
|
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsRoot, name), []byte("secret-value\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`
|
||||||
|
secrets:
|
||||||
|
directory: `+secretsRoot+`
|
||||||
|
pipelines:
|
||||||
|
- id: reports
|
||||||
|
source:
|
||||||
|
backend: local
|
||||||
|
path: `+sourceRoot+`
|
||||||
|
destinations:
|
||||||
|
- id: archive
|
||||||
|
backend: local
|
||||||
|
path: `+destinationRoot+`
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Execute(context.Background(), []string{"run", "--config", configPath, "--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)
|
||||||
|
warnings, ok := envelope["warnings"].([]any)
|
||||||
|
if !ok || len(warnings) != 1 {
|
||||||
|
t.Fatalf("warnings = %#v, want one warning", envelope["warnings"])
|
||||||
|
}
|
||||||
|
warning, ok := warnings[0].(map[string]any)
|
||||||
|
if !ok || !strings.Contains(fmt.Sprint(warning["message"]), name) {
|
||||||
|
t.Fatalf("warning = %#v, want secret name", warnings[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), "Warning:") || strings.Contains(stdout.String(), "process-value") || strings.Contains(stdout.String(), "secret-value") {
|
||||||
|
t.Fatalf("stdout exposed text warning or secret values: %q", stdout.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunJSONFatalSetupErrorWritesNoJSON(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute(context.Background(), []string{"run", "--config", filepath.Join(t.TempDir(), "missing.yml"), "--format", "json"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitError {
|
||||||
|
t.Fatalf("exit code = %d, want %d", code, exitError)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "no such file or directory") {
|
||||||
|
t.Fatalf("stderr = %q, want setup error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunJSONPartialFailure(t *testing.T) {
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
firstDestination := t.TempDir()
|
||||||
|
secondDestination := t.TempDir()
|
||||||
|
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||||
|
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write unmanaged file: %v", err)
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`
|
||||||
|
pipelines:
|
||||||
|
- id: reports
|
||||||
|
source:
|
||||||
|
backend: local
|
||||||
|
path: `+sourceRoot+`
|
||||||
|
destinations:
|
||||||
|
- id: archive-one
|
||||||
|
backend: local
|
||||||
|
path: `+firstDestination+`
|
||||||
|
- id: archive-two
|
||||||
|
backend: local
|
||||||
|
path: `+secondDestination+`
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Execute(context.Background(), []string{"run", "--config", configPath, "--format", "json"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code != exitError {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitError, stderr.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty for partial JSON result", stderr.String())
|
||||||
|
}
|
||||||
|
envelope := decodeEnvelope(t, &stdout)
|
||||||
|
if envelope["command"] != "run" || envelope["ok"] != false {
|
||||||
|
t.Fatalf("envelope = %#v, want failed run envelope", envelope)
|
||||||
|
}
|
||||||
|
errors, ok := envelope["errors"].([]any)
|
||||||
|
if !ok || len(errors) != 1 {
|
||||||
|
t.Fatalf("errors = %#v, want one error", envelope["errors"])
|
||||||
|
}
|
||||||
|
result := envelopeResult(t, envelope)
|
||||||
|
summary, ok := result["summary"].(map[string]any)
|
||||||
|
if !ok || summary["status"] != "failed" || summary["failed"] != float64(1) {
|
||||||
|
t.Fatalf("summary = %#v, want failed summary", result["summary"])
|
||||||
|
}
|
||||||
|
actions, ok := result["actions"].([]any)
|
||||||
|
if !ok || len(actions) != 2 {
|
||||||
|
t.Fatalf("actions = %#v, want two actions", result["actions"])
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(secondDestination, storage.StateFileName)); err != nil {
|
||||||
|
t.Fatalf("second destination state stat error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
|
func TestExecuteRunForceDryRunReportsWithoutWriting(t *testing.T) {
|
||||||
sourceRoot := t.TempDir()
|
sourceRoot := t.TempDir()
|
||||||
destinationRoot := t.TempDir()
|
destinationRoot := t.TempDir()
|
||||||
|
|||||||
@@ -20,19 +20,28 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in
|
|||||||
configPath := flags.String("config", "", "path to config file")
|
configPath := flags.String("config", "", "path to config file")
|
||||||
dryRun := flags.Bool("dry-run", false, "load and validate config without publishing")
|
dryRun := flags.Bool("dry-run", false, "load and validate config without publishing")
|
||||||
force := flags.Bool("force", false, "allow explicit destructive replacement for supported conflicts")
|
force := flags.Bool("force", false, "allow explicit destructive replacement for supported conflicts")
|
||||||
|
formatFlag := addFormatFlag(flags)
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return exitUsage
|
return exitUsage
|
||||||
}
|
}
|
||||||
if rejectPositionalArgs(stderr, "run", flags.Args()) {
|
if rejectPositionalArgs(stderr, "run", flags.Args()) {
|
||||||
return exitUsage
|
return exitUsage
|
||||||
}
|
}
|
||||||
|
format, ok := parseOutputFormat(stderr, "run", *formatFlag)
|
||||||
|
if !ok {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Run(ctx, app.RunOptions{
|
if err := app.Run(ctx, app.RunOptions{
|
||||||
ConfigPath: *configPath,
|
ConfigPath: *configPath,
|
||||||
DryRun: *dryRun,
|
DryRun: *dryRun,
|
||||||
Force: *force,
|
Force: *force,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
|
OutputFormat: format,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
if app.IsJSONOutput(format) && app.IsPartialResultError(err) {
|
||||||
|
return exitError
|
||||||
|
}
|
||||||
return fail(stderr, err)
|
return fail(stderr, err)
|
||||||
}
|
}
|
||||||
return exitOK
|
return exitOK
|
||||||
@@ -40,12 +49,14 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in
|
|||||||
|
|
||||||
func printRunHelp(w io.Writer) {
|
func printRunHelp(w io.Writer) {
|
||||||
fmt.Fprint(w, `Usage:
|
fmt.Fprint(w, `Usage:
|
||||||
distributor run --config <path> [--dry-run] [--force]
|
distributor run --config <path> [--dry-run] [--force] [--format text|json]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--config <path> Path to config file
|
--config <path> Path to config file
|
||||||
--dry-run Load and validate config without publishing
|
--dry-run Load and validate config without publishing
|
||||||
--force Allow explicit destructive replacement for supported conflicts
|
--force Allow explicit destructive replacement for supported conflicts
|
||||||
|
--format text|json
|
||||||
|
Output format
|
||||||
|
|
||||||
Run discovers configured source bundles, plans each destination, publishes
|
Run discovers configured source bundles, plans each destination, publishes
|
||||||
selected outputs unless --dry-run is set, and prints a final status summary.
|
selected outputs unless --dry-run is set, and prints a final status summary.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -13,11 +14,21 @@ func validateCommand(ctx context.Context, args []string, stdout, stderr io.Write
|
|||||||
printValidateHelp(stdout)
|
printValidateHelp(stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
path, ok := parseOptionalPathArg(stderr, "validate", args)
|
flags := flag.NewFlagSet("validate", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
formatFlag := addFormatFlag(flags)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
format, ok := parseOutputFormat(stderr, "validate", *formatFlag)
|
||||||
if !ok {
|
if !ok {
|
||||||
return exitUsage
|
return exitUsage
|
||||||
}
|
}
|
||||||
if err := app.Validate(ctx, app.ValidateOptions{Path: path, Stdout: stdout}); err != nil {
|
path, ok := parseOptionalPathArg(stderr, "validate", flags.Args())
|
||||||
|
if !ok {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if err := app.Validate(ctx, app.ValidateOptions{Path: path, Stdout: stdout, OutputFormat: format}); err != nil {
|
||||||
return fail(stderr, err)
|
return fail(stderr, err)
|
||||||
}
|
}
|
||||||
return exitOK
|
return exitOK
|
||||||
@@ -25,7 +36,10 @@ func validateCommand(ctx context.Context, args []string, stdout, stderr io.Write
|
|||||||
|
|
||||||
func printValidateHelp(w io.Writer) {
|
func printValidateHelp(w io.Writer) {
|
||||||
fmt.Fprint(w, `Usage:
|
fmt.Fprint(w, `Usage:
|
||||||
distributor validate <path>
|
distributor validate [--format text|json] <path>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--format text|json Output format
|
||||||
|
|
||||||
Validate a local source bundle directory or a tree containing source bundles.
|
Validate a local source bundle directory or a tree containing source bundles.
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -13,16 +14,44 @@ func versionCommand(_ context.Context, args []string, stdout, stderr io.Writer)
|
|||||||
printVersionHelp(stdout)
|
printVersionHelp(stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
if rejectExtraArgs(stderr, "version", args) {
|
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
formatFlag := addFormatFlag(flags)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
return exitUsage
|
return exitUsage
|
||||||
}
|
}
|
||||||
|
if rejectExtraArgs(stderr, "version", flags.Args()) {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
format, ok := parseOutputFormat(stderr, "version", *formatFlag)
|
||||||
|
if !ok {
|
||||||
|
return exitUsage
|
||||||
|
}
|
||||||
|
if app.IsJSONOutput(format) {
|
||||||
|
err := app.WriteJSONEnvelope(stdout, "version", true, nil, versionResult{
|
||||||
|
Application: app.Name,
|
||||||
|
Version: app.Version,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fail(stderr, err)
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
fmt.Fprintln(stdout, app.VersionString())
|
fmt.Fprintln(stdout, app.VersionString())
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type versionResult struct {
|
||||||
|
Application string `json:"application"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
func printVersionHelp(w io.Writer) {
|
func printVersionHelp(w io.Writer) {
|
||||||
fmt.Fprint(w, `Usage:
|
fmt.Fprint(w, `Usage:
|
||||||
distributor version
|
distributor version [--format text|json]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--format text|json Output format
|
||||||
|
|
||||||
Print version information.
|
Print version information.
|
||||||
`)
|
`)
|
||||||
|
|||||||
Reference in New Issue
Block a user