Add configured source validation and inspection
This commit is contained in:
37
docs/cli.md
37
docs/cli.md
@@ -15,17 +15,19 @@ distributor [--help]
|
||||
distributor version [--format text|json]
|
||||
distributor run [--config <path>] [--dry-run] [--force] [--format text|json]
|
||||
distributor validate [--format text|json] <path>
|
||||
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
||||
distributor inspect [--format text|json] <path>
|
||||
distributor inspect --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
||||
distributor manifest create <bundle-path> --id <bundle-id> [options]
|
||||
```
|
||||
|
||||
- `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.
|
||||
- `validate`: validates a local source bundle directory or a local tree containing source bundles.
|
||||
- `inspect`: validates local source bundles and prints normalized bundle metadata.
|
||||
- `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.
|
||||
- `manifest create`: creates `manifest.json` for a local source bundle directory.
|
||||
|
||||
`validate` and `inspect` accept local paths only. `run` executes `local`, `ssh`, and `s3` backends.
|
||||
`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.
|
||||
|
||||
## Flag reference
|
||||
|
||||
@@ -47,6 +49,12 @@ 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.
|
||||
- `--force`: allow explicit destructive replacement for supported conflict cases in this run only.
|
||||
|
||||
`validate` and `inspect` configured source flags:
|
||||
|
||||
- `--config <path>`: config file to load for source validation or inspection. Required in configured source mode.
|
||||
- `--pipeline <id>`: pipeline source to validate or inspect. Required in configured source mode.
|
||||
- `--bundle <path>`: source-root-relative bundle directory to validate or inspect instead of discovering every bundle under the source root.
|
||||
|
||||
`manifest create` flags:
|
||||
|
||||
- `--id <bundle-id>`: source bundle id. Required.
|
||||
@@ -54,7 +62,7 @@ Output-producing subcommands:
|
||||
- `--created <time>`: RFC3339 source created timestamp. If omitted, the current UTC time is used.
|
||||
- `--overwrite`: replace an existing `manifest.json`.
|
||||
|
||||
`run` does not accept positional arguments. `validate` and `inspect` accept at most one path; omitting the path returns a required-path error.
|
||||
`run` does not accept positional arguments. `validate` and `inspect` accept at most one path in local mode. Local paths cannot be combined with `--config`, `--pipeline`, or `--bundle`.
|
||||
|
||||
## Common workflows
|
||||
|
||||
@@ -70,6 +78,21 @@ Inspect a source bundle:
|
||||
go run ./cmd/distributor inspect examples/source-bundle
|
||||
```
|
||||
|
||||
Validate a configured source without opening destinations:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle
|
||||
```
|
||||
|
||||
Inspect one configured source bundle:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor inspect \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--bundle daily/2026-06-01
|
||||
```
|
||||
|
||||
Create a manifest for a local producer bundle:
|
||||
|
||||
```sh
|
||||
@@ -152,13 +175,13 @@ Warnings are objects in the top-level `warnings` array and are not printed again
|
||||
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.
|
||||
- `validate`: bundle count and discovered bundle identifiers. Configured source results also include pipeline id and source backend.
|
||||
- `inspect`: bundle path, id, created timestamp, digest, file count, total size, and manifest file records. Configured source results also include pipeline id and source backend.
|
||||
- `manifest create`: manifest path, bundle root, id, created timestamp, digest, file count, and file records.
|
||||
- `run`: dry-run status, pipeline summaries, destination action records, output records, final counters, warnings, and partial failure records.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Use `manifest create` when a local producer has written bundle files but not `manifest.json`. 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 `manifest create` when a local producer has written bundle files but not `manifest.json`. Use `validate` before publication when a producer has written a new bundle; use configured source mode when the bundle is already on an SSH or S3 source. Use `inspect` to confirm normalized ids, timestamps, digests, file paths, and file sizes.
|
||||
|
||||
For symptom-oriented recovery steps, see [troubleshooting](troubleshooting.md). For destination state and retry behavior, see [operations](operations.md). For config fields and defaults, see [configuration](config.md).
|
||||
|
||||
@@ -50,6 +50,12 @@ Preview an environment-gated S3 destination config after editing it for an S3-co
|
||||
go run ./cmd/distributor run --config examples/s3-destination.yml --dry-run
|
||||
```
|
||||
|
||||
Validate one configured source without opening destinations:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --config examples/local-publish.yml --pipeline example-source-bundle
|
||||
```
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
Source bundles are discovered beneath the configured source root. Each bundle is a directory containing `manifest.json`.
|
||||
@@ -109,6 +115,28 @@ go run ./cmd/distributor validate <bundle-path>
|
||||
|
||||
Use repeated `--file` flags to preserve a specific file order. If no `--file` flags are provided, the command scans the bundle directory recursively using the same filtering rules as `pkg/bundle.BuildManifest`.
|
||||
|
||||
## Source Validation and Inspection
|
||||
|
||||
`validate` and `inspect` can operate on a local path or on one configured pipeline source. Configured source mode requires both `--config` and `--pipeline`; it loads the normal config, resolves `secrets.directory`, opens only the selected source backend, and does not open any destinations.
|
||||
|
||||
Configured source validation is useful when producers write directly to SSH or S3 storage:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
|
||||
go run ./cmd/distributor inspect --config <config-path> --pipeline <pipeline-id>
|
||||
```
|
||||
|
||||
Use `--bundle <path>` to validate or inspect one source-root-relative bundle directory:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate \
|
||||
--config <config-path> \
|
||||
--pipeline <pipeline-id> \
|
||||
--bundle daily/2026-06-01
|
||||
```
|
||||
|
||||
For configured SSH sources, host key and authentication behavior matches `run`. For configured S3 sources, endpoint, bucket, prefix, region, path-style, explicit credential environment variables, and `secrets.directory` handling match `run`.
|
||||
|
||||
## Dry Runs
|
||||
|
||||
`--dry-run` loads and validates config, discovers source bundles, inspects destination state, plans outputs, and prints summary lines. It does not write output files, destination state, or SSH `known_hosts` entries.
|
||||
@@ -181,9 +209,9 @@ secrets:
|
||||
directory: /run/secrets/distributor
|
||||
```
|
||||
|
||||
The directory is loaded during `run` before any source or destination backend is opened. If the directory is missing, unreadable, or contains an invalid secret filename, the run fails before publication work starts.
|
||||
The directory is loaded during `run` and configured-source `validate` or `inspect` before any backend is opened. If the directory is missing, unreadable, or contains an invalid secret filename, the command fails before storage work starts.
|
||||
|
||||
Real process environment values take precedence over files with the same name. If the values differ and stdout is enabled, `run` prints a warning naming the ignored secret file variable without printing either value. The process environment is not changed.
|
||||
Real process environment values take precedence over files with the same name. If the values differ and stdout is enabled, `run` and configured-source diagnostics print a warning naming the ignored secret file variable without printing either value. The process environment is not changed.
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@@ -60,6 +60,31 @@ 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.
|
||||
|
||||
## `configured source mode requires --pipeline`
|
||||
|
||||
Likely cause: `validate` or `inspect` was run with `--config` but without an explicit pipeline id.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --help
|
||||
go run ./cmd/distributor inspect --help
|
||||
```
|
||||
|
||||
Safe fix: add `--pipeline <pipeline-id>`. Configured source diagnostics require an explicit pipeline even when the config contains one pipeline.
|
||||
|
||||
## `does not accept a local path with --config, --pipeline, or --bundle`
|
||||
|
||||
Likely cause: local-path mode and configured source mode were mixed in one `validate` or `inspect` command.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor inspect --help
|
||||
```
|
||||
|
||||
Safe fix: use either `distributor inspect <local-path>` or `distributor inspect --config <path> --pipeline <id>`, not both.
|
||||
|
||||
## `--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.
|
||||
@@ -234,21 +259,33 @@ ssh-keygen -F <host> -f <known-hosts-path>
|
||||
|
||||
Safe fix: verify the server identity out of band before updating `known_hosts`. Do not switch to `host_key_policy: off` to bypass an unexpected changed key.
|
||||
|
||||
## `stat ssh ... not_found` or `no bundles found`
|
||||
## `pipeline "<id>" not found`
|
||||
|
||||
Likely cause: the configured SSH `path` is wrong, unreadable, or does not contain source bundles.
|
||||
Likely cause: configured source validation or inspection requested a pipeline id that is not present in the config file.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
sftp <user>@<host>
|
||||
rg -n "id:" <config-path>
|
||||
```
|
||||
|
||||
Safe fix: correct the remote root `path`, permissions, or source bundle location.
|
||||
Safe fix: pass an existing pipeline id with `--pipeline`, or update the config.
|
||||
|
||||
## `stat ssh ... not_found`, `stat s3 ... not_found`, or `no bundles found`
|
||||
|
||||
Likely cause: the configured source root is wrong, unreadable, or does not contain source bundles.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
go run ./cmd/distributor validate --config <config-path> --pipeline <pipeline-id>
|
||||
```
|
||||
|
||||
Safe fix: correct the configured source root, S3 prefix, permissions, or source bundle location. Use `--bundle <path>` only with a source-root-relative bundle directory that contains `manifest.json`.
|
||||
|
||||
## `validate command requires a path` or `inspect command requires a path`
|
||||
|
||||
Likely cause: `validate` or `inspect` was run without a path.
|
||||
Likely cause: `validate` or `inspect` was run without a local path and without configured source mode.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
@@ -257,7 +294,7 @@ go run ./cmd/distributor validate --help
|
||||
go run ./cmd/distributor inspect --help
|
||||
```
|
||||
|
||||
Safe fix: pass a local source bundle directory or a local tree containing source bundles.
|
||||
Safe fix: pass a local source bundle directory or local tree, or pass both `--config <path>` and `--pipeline <id>`.
|
||||
|
||||
## `no bundles found under "."`
|
||||
|
||||
|
||||
@@ -5,40 +5,70 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type InspectOptions struct {
|
||||
Path string
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
BundlePath string
|
||||
Stdout io.Writer
|
||||
OutputFormat OutputFormat
|
||||
}
|
||||
|
||||
func Inspect(ctx context.Context, options InspectOptions) error {
|
||||
return inspectWithBackendFactory(ctx, options, newBackendFactoryWithEnvironment)
|
||||
}
|
||||
|
||||
func inspectWithBackendFactory(ctx context.Context, options InspectOptions, provider backendFactoryProvider) error {
|
||||
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.Path == "" {
|
||||
return fmt.Errorf("inspect command requires a path")
|
||||
}
|
||||
backend, err := newBackendFactory().openLocalPath(ctx, options.Path)
|
||||
selection, err := selectSourceBundles(ctx, sourceCommandOptions{
|
||||
CommandName: "inspect",
|
||||
Path: options.Path,
|
||||
ConfigPath: options.ConfigPath,
|
||||
PipelineID: options.PipelineID,
|
||||
BundlePath: options.BundlePath,
|
||||
}, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bundles, err := bundle.Discover(ctx, backend, "")
|
||||
return writeInspectResult(options, selection)
|
||||
}
|
||||
|
||||
func inspectConfigWithBackendFactory(ctx context.Context, cfg config.Config, options InspectOptions, provider backendFactoryProvider) error {
|
||||
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||
return err
|
||||
}
|
||||
selection, err := selectSourceBundlesFromConfig(ctx, cfg, sourceCommandOptions{
|
||||
CommandName: "inspect",
|
||||
PipelineID: options.PipelineID,
|
||||
BundlePath: options.BundlePath,
|
||||
}, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeInspectResult(options, selection)
|
||||
}
|
||||
|
||||
func writeInspectResult(options InspectOptions, selection sourceSelection) error {
|
||||
if IsJSONOutput(options.OutputFormat) {
|
||||
return WriteJSONEnvelope(options.Stdout, "inspect", true, nil, inspectResultFromBundles(bundles), nil)
|
||||
return WriteJSONEnvelope(options.Stdout, "inspect", true, selection.Warnings, inspectResultFromSelection(selection), nil)
|
||||
}
|
||||
return writeInspection(options.Stdout, bundles)
|
||||
if err := writeWarnings(options.Stdout, selection.Warnings); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeInspection(options.Stdout, selection)
|
||||
}
|
||||
|
||||
type inspectResult struct {
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []inspectBundleResult `json:"bundles"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
SourceBackend string `json:"source_backend,omitempty"`
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []inspectBundleResult `json:"bundles"`
|
||||
}
|
||||
|
||||
type inspectBundleResult struct {
|
||||
@@ -57,12 +87,14 @@ type inspectFileResult struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func inspectResultFromBundles(bundles []bundle.Bundle) inspectResult {
|
||||
func inspectResultFromSelection(selection sourceSelection) inspectResult {
|
||||
result := inspectResult{
|
||||
BundleCount: len(bundles),
|
||||
Bundles: make([]inspectBundleResult, 0, len(bundles)),
|
||||
PipelineID: selection.PipelineID,
|
||||
SourceBackend: selection.SourceBackend,
|
||||
BundleCount: len(selection.Bundles),
|
||||
Bundles: make([]inspectBundleResult, 0, len(selection.Bundles)),
|
||||
}
|
||||
for _, sourceBundle := range bundles {
|
||||
for _, sourceBundle := range selection.Bundles {
|
||||
bundleResult := inspectBundleResult{
|
||||
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
|
||||
ID: sourceBundle.Manifest.ID,
|
||||
@@ -84,14 +116,19 @@ func inspectResultFromBundles(bundles []bundle.Bundle) inspectResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func writeInspection(w io.Writer, bundles []bundle.Bundle) error {
|
||||
func writeInspection(w io.Writer, selection sourceSelection) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "Bundles: %d\n", len(bundles)); err != nil {
|
||||
if selection.ConfigMode {
|
||||
if _, err := fmt.Fprintf(w, "Pipeline: %s\nSource: %s\n", selection.PipelineID, selection.SourceBackend); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "Bundles: %d\n", len(selection.Bundles)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sourceBundle := range bundles {
|
||||
for _, sourceBundle := range selection.Bundles {
|
||||
if _, err := fmt.Fprintf(
|
||||
w,
|
||||
"- path=%s id=%s created=%s digest=%s files=%d\n",
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestInspectPrintsBundleSummary(t *testing.T) {
|
||||
@@ -32,6 +34,57 @@ func TestInspectPrintsBundleSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectConfiguredLocalSource(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{ID: "reports.daily"})
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Inspect(context.Background(), InspectOptions{
|
||||
ConfigPath: testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot),
|
||||
PipelineID: "reports",
|
||||
Stdout: &stdout,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() configured source error = %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Pipeline: reports",
|
||||
"Source: local",
|
||||
"Bundles: 1",
|
||||
"path=daily",
|
||||
"id=reports.daily",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("Inspect() output = %q, want substring %q", output, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectConfiguredSourceJSON(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{ID: "reports.json"})
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Inspect(context.Background(), InspectOptions{
|
||||
ConfigPath: testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot),
|
||||
PipelineID: "reports",
|
||||
Stdout: &stdout,
|
||||
OutputFormat: OutputFormatJSON,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Inspect() configured JSON error = %v", err)
|
||||
}
|
||||
result := decodeAppResult(t, stdout.String())
|
||||
if result["pipeline_id"] != "reports" || result["source_backend"] != "local" || result["bundle_count"] != float64(1) {
|
||||
t.Fatalf("result = %#v, want configured inspect metadata", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRequiresPath(t *testing.T) {
|
||||
err := Inspect(context.Background(), InspectOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a path") {
|
||||
|
||||
@@ -310,6 +310,9 @@ func sshWarnings(pipeline config.Pipeline) []OutputWarning {
|
||||
}
|
||||
|
||||
func writeWarnings(w io.Writer, warnings []OutputWarning) error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
if _, err := fmt.Fprintf(w, "Warning: %s\n", warning.Message); err != nil {
|
||||
return err
|
||||
|
||||
120
internal/app/source_select.go
Normal file
120
internal/app/source_select.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type sourceCommandOptions struct {
|
||||
CommandName string
|
||||
Path string
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
BundlePath string
|
||||
}
|
||||
|
||||
type sourceSelection struct {
|
||||
Bundles []bundle.Bundle
|
||||
PipelineID string
|
||||
SourceBackend string
|
||||
ConfigMode bool
|
||||
Warnings []OutputWarning
|
||||
}
|
||||
|
||||
func selectSourceBundles(ctx context.Context, options sourceCommandOptions, provider backendFactoryProvider) (sourceSelection, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
if options.ConfigPath != "" {
|
||||
cfg, err := config.LoadFile(options.ConfigPath)
|
||||
if err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
return selectSourceBundlesFromConfig(ctx, cfg, options, provider)
|
||||
}
|
||||
if options.PipelineID != "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode requires --config")
|
||||
}
|
||||
if options.BundlePath != "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode requires --config")
|
||||
}
|
||||
if options.Path == "" {
|
||||
return sourceSelection{}, fmt.Errorf("%s command requires a path", options.CommandName)
|
||||
}
|
||||
backend, err := newBackendFactory().openLocalPath(ctx, options.Path)
|
||||
if err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
defer closeBackend(backend)
|
||||
bundles, err := bundle.Discover(ctx, backend, "")
|
||||
if err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
return sourceSelection{Bundles: bundles}, nil
|
||||
}
|
||||
|
||||
func selectSourceBundlesFromConfig(ctx context.Context, cfg config.Config, options sourceCommandOptions, provider backendFactoryProvider) (sourceSelection, error) {
|
||||
if options.Path != "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode does not accept a local path")
|
||||
}
|
||||
if options.PipelineID == "" {
|
||||
return sourceSelection{}, fmt.Errorf("configured source mode requires --pipeline")
|
||||
}
|
||||
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
|
||||
if err != nil {
|
||||
return sourceSelection{}, err
|
||||
}
|
||||
pipeline, ok := findPipeline(cfg, options.PipelineID)
|
||||
if !ok {
|
||||
return sourceSelection{}, fmt.Errorf("pipeline %q not found", options.PipelineID)
|
||||
}
|
||||
backends := provider(secretLoad.Environment)
|
||||
sourceBackend, err := backends.openSource(ctx, pipeline.Source)
|
||||
if err != nil {
|
||||
return sourceSelection{}, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err)
|
||||
}
|
||||
defer closeBackend(sourceBackend)
|
||||
|
||||
var bundles []bundle.Bundle
|
||||
if options.BundlePath != "" {
|
||||
sourceBundle, err := bundle.Validate(ctx, sourceBackend, options.BundlePath)
|
||||
if err != nil {
|
||||
return sourceSelection{}, fmt.Errorf("pipeline %s source backend %s bundle %s: %w", pipeline.ID, pipeline.Source.Backend, storage.DisplayPath(options.BundlePath), err)
|
||||
}
|
||||
bundles = []bundle.Bundle{sourceBundle}
|
||||
} else {
|
||||
bundles, err = bundle.Discover(ctx, sourceBackend, "")
|
||||
if err != nil {
|
||||
return sourceSelection{}, fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
|
||||
}
|
||||
}
|
||||
return sourceSelection{
|
||||
Bundles: bundles,
|
||||
PipelineID: pipeline.ID,
|
||||
SourceBackend: pipeline.Source.Backend,
|
||||
ConfigMode: true,
|
||||
Warnings: append(secretConflictWarnings(secretLoad.Conflicts), sourceSSHWarnings(pipeline)...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findPipeline(cfg config.Config, id string) (config.Pipeline, bool) {
|
||||
for _, pipeline := range cfg.Pipelines {
|
||||
if pipeline.ID == id {
|
||||
return pipeline, true
|
||||
}
|
||||
}
|
||||
return config.Pipeline{}, false
|
||||
}
|
||||
|
||||
func sourceSSHWarnings(pipeline config.Pipeline) []OutputWarning {
|
||||
if pipeline.Source.Backend != config.BackendSSH || pipeline.Source.SSH.HostKeyPolicy != config.HostKeyPolicyOff {
|
||||
return nil
|
||||
}
|
||||
return []OutputWarning{{
|
||||
Message: fmt.Sprintf("pipeline=%s source host_key_policy=off disables SSH host key checking", pipeline.ID),
|
||||
}}
|
||||
}
|
||||
@@ -5,43 +5,78 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
)
|
||||
|
||||
type ValidateOptions struct {
|
||||
Path string
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
BundlePath string
|
||||
Stdout io.Writer
|
||||
OutputFormat OutputFormat
|
||||
}
|
||||
|
||||
func Validate(ctx context.Context, options ValidateOptions) error {
|
||||
return validateWithBackendFactory(ctx, options, newBackendFactoryWithEnvironment)
|
||||
}
|
||||
|
||||
func validateWithBackendFactory(ctx context.Context, options ValidateOptions, provider backendFactoryProvider) error {
|
||||
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.Path == "" {
|
||||
return fmt.Errorf("validate command requires a path")
|
||||
}
|
||||
backend, err := newBackendFactory().openLocalPath(ctx, options.Path)
|
||||
selection, err := selectSourceBundles(ctx, sourceCommandOptions{
|
||||
CommandName: "validate",
|
||||
Path: options.Path,
|
||||
ConfigPath: options.ConfigPath,
|
||||
PipelineID: options.PipelineID,
|
||||
BundlePath: options.BundlePath,
|
||||
}, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bundles, err := bundle.Discover(ctx, backend, "")
|
||||
return writeValidateResult(options, selection)
|
||||
}
|
||||
|
||||
func validateConfigWithBackendFactory(ctx context.Context, cfg config.Config, options ValidateOptions, provider backendFactoryProvider) error {
|
||||
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
|
||||
return err
|
||||
}
|
||||
selection, err := selectSourceBundlesFromConfig(ctx, cfg, sourceCommandOptions{
|
||||
CommandName: "validate",
|
||||
PipelineID: options.PipelineID,
|
||||
BundlePath: options.BundlePath,
|
||||
}, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeValidateResult(options, selection)
|
||||
}
|
||||
|
||||
func writeValidateResult(options ValidateOptions, selection sourceSelection) error {
|
||||
if IsJSONOutput(options.OutputFormat) {
|
||||
return WriteJSONEnvelope(options.Stdout, "validate", true, nil, validateResultFromBundles(bundles), nil)
|
||||
return WriteJSONEnvelope(options.Stdout, "validate", true, selection.Warnings, validateResultFromSelection(selection), nil)
|
||||
}
|
||||
var err error
|
||||
if options.Stdout != nil {
|
||||
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s)\n", len(bundles))
|
||||
if err := writeWarnings(options.Stdout, selection.Warnings); err != nil {
|
||||
return err
|
||||
}
|
||||
if selection.ConfigMode {
|
||||
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s) for pipeline %s source %s\n", len(selection.Bundles), selection.PipelineID, selection.SourceBackend)
|
||||
} else {
|
||||
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s)\n", len(selection.Bundles))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type validateResult struct {
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []validateBundleResult `json:"bundles"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
SourceBackend string `json:"source_backend,omitempty"`
|
||||
BundleCount int `json:"bundle_count"`
|
||||
Bundles []validateBundleResult `json:"bundles"`
|
||||
}
|
||||
|
||||
type validateBundleResult struct {
|
||||
@@ -49,12 +84,14 @@ type validateBundleResult struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func validateResultFromBundles(bundles []bundle.Bundle) validateResult {
|
||||
func validateResultFromSelection(selection sourceSelection) validateResult {
|
||||
result := validateResult{
|
||||
BundleCount: len(bundles),
|
||||
Bundles: make([]validateBundleResult, 0, len(bundles)),
|
||||
PipelineID: selection.PipelineID,
|
||||
SourceBackend: selection.SourceBackend,
|
||||
BundleCount: len(selection.Bundles),
|
||||
Bundles: make([]validateBundleResult, 0, len(selection.Bundles)),
|
||||
}
|
||||
for _, sourceBundle := range bundles {
|
||||
for _, sourceBundle := range selection.Bundles {
|
||||
result.Bundles = append(result.Bundles, validateBundleResult{
|
||||
Path: storage.DisplayPath(sourceBundle.RootRelativePath),
|
||||
ID: sourceBundle.Manifest.ID,
|
||||
|
||||
@@ -3,9 +3,15 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||
)
|
||||
|
||||
func TestValidateLocalBundle(t *testing.T) {
|
||||
@@ -31,9 +37,182 @@ func TestValidateExampleSourceBundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfiguredLocalSource(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Validate(context.Background(), ValidateOptions{
|
||||
ConfigPath: testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot),
|
||||
PipelineID: "reports",
|
||||
Stdout: &stdout,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() configured source error = %v", err)
|
||||
}
|
||||
if got, want := stdout.String(), "Validated 1 bundle(s) for pipeline reports source local\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfiguredSourceBundlePath(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "daily/one", testutil.BundleOptions{ID: "reports.one"})
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "daily/two", testutil.BundleOptions{ID: "reports.two"})
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := Validate(context.Background(), ValidateOptions{
|
||||
ConfigPath: testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot),
|
||||
PipelineID: "reports",
|
||||
BundlePath: "daily/two",
|
||||
Stdout: &stdout,
|
||||
OutputFormat: OutputFormatJSON,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() configured bundle error = %v", err)
|
||||
}
|
||||
result := decodeAppResult(t, stdout.String())
|
||||
if result["pipeline_id"] != "reports" || result["source_backend"] != "local" || result["bundle_count"] != float64(1) {
|
||||
t.Fatalf("result = %#v, want configured source summary", result)
|
||||
}
|
||||
bundles, ok := result["bundles"].([]any)
|
||||
if !ok || len(bundles) != 1 {
|
||||
t.Fatalf("bundles = %#v, want one bundle", result["bundles"])
|
||||
}
|
||||
sourceBundle, ok := bundles[0].(map[string]any)
|
||||
if !ok || sourceBundle["path"] != "daily/two" || sourceBundle["id"] != "reports.two" {
|
||||
t.Fatalf("bundle = %#v, want narrowed bundle", sourceBundle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfiguredRemoteSourcesThroughStorageAbstraction(t *testing.T) {
|
||||
s3Source := fake.New()
|
||||
testutil.WriteFakeSourceBundle(t, s3Source, "", testutil.BundleOptions{ID: "reports.s3"})
|
||||
sshSource := fake.New()
|
||||
testutil.WriteFakeSourceBundle(t, sshSource, "daily", testutil.BundleOptions{ID: "reports.ssh"})
|
||||
cfg := config.Config{Pipelines: []config.Pipeline{
|
||||
{
|
||||
ID: "s3-reports",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendS3,
|
||||
Endpoint: "http://s3.test",
|
||||
Bucket: "source-bucket",
|
||||
},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
Backend: config.BackendLocal,
|
||||
Path: t.TempDir(),
|
||||
}},
|
||||
},
|
||||
{
|
||||
ID: "ssh-reports",
|
||||
Source: config.Backend{
|
||||
Backend: config.BackendSSH,
|
||||
Host: "ssh.test",
|
||||
Path: "/source",
|
||||
},
|
||||
Destinations: []config.Destination{{
|
||||
ID: "archive",
|
||||
Backend: config.BackendLocal,
|
||||
Path: t.TempDir(),
|
||||
}},
|
||||
},
|
||||
}}
|
||||
config.ApplyDefaults(&cfg)
|
||||
provider := fakeBackendFactoryProvider(t, map[string]storage.Backend{
|
||||
"s3:source-bucket": s3Source,
|
||||
"ssh:/source": sshSource,
|
||||
})
|
||||
|
||||
var s3Stdout bytes.Buffer
|
||||
if err := validateConfigWithBackendFactory(context.Background(), cfg, ValidateOptions{
|
||||
PipelineID: "s3-reports",
|
||||
Stdout: &s3Stdout,
|
||||
OutputFormat: OutputFormatJSON,
|
||||
}, provider); err != nil {
|
||||
t.Fatalf("validate s3 source error = %v", err)
|
||||
}
|
||||
s3Result := decodeAppResult(t, s3Stdout.String())
|
||||
if s3Result["source_backend"] != "s3" || s3Result["bundle_count"] != float64(1) {
|
||||
t.Fatalf("s3 result = %#v, want one s3 bundle", s3Result)
|
||||
}
|
||||
|
||||
var sshStdout bytes.Buffer
|
||||
if err := validateConfigWithBackendFactory(context.Background(), cfg, ValidateOptions{
|
||||
PipelineID: "ssh-reports",
|
||||
BundlePath: "daily",
|
||||
Stdout: &sshStdout,
|
||||
}, provider); err != nil {
|
||||
t.Fatalf("validate ssh source error = %v", err)
|
||||
}
|
||||
if !strings.Contains(sshStdout.String(), "pipeline ssh-reports source ssh") {
|
||||
t.Fatalf("ssh stdout = %q, want ssh source summary", sshStdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfiguredSourceLoadsSecretsBeforeOpeningBackend(t *testing.T) {
|
||||
sourceRoot := filepath.Join(t.TempDir(), "missing-source")
|
||||
destinationRoot := t.TempDir()
|
||||
configPath := writeConfigFile(t, `
|
||||
secrets:
|
||||
directory: `+filepath.Join(t.TempDir(), "missing-secrets")+`
|
||||
pipelines:
|
||||
- id: reports
|
||||
source:
|
||||
backend: local
|
||||
path: `+sourceRoot+`
|
||||
destinations:
|
||||
- id: archive
|
||||
backend: local
|
||||
path: `+destinationRoot+`
|
||||
`)
|
||||
|
||||
err := Validate(context.Background(), ValidateOptions{ConfigPath: configPath, PipelineID: "reports"})
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want secrets directory error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "load secrets directory") {
|
||||
t.Fatalf("Validate() error = %v, want secrets directory error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "missing-source") {
|
||||
t.Fatalf("Validate() error = %v, opened source before loading secrets", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfiguredSourceRequiresPipeline(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
|
||||
err := Validate(context.Background(), ValidateOptions{
|
||||
ConfigPath: testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot),
|
||||
})
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "requires --pipeline") {
|
||||
t.Fatalf("Validate() error = %v, want required pipeline", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresPath(t *testing.T) {
|
||||
err := Validate(context.Background(), ValidateOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a path") {
|
||||
t.Fatalf("Validate() error = %v, want required path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAppResult(t *testing.T, output string) map[string]any {
|
||||
t.Helper()
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(output), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v; output = %q", err, output)
|
||||
}
|
||||
result, ok := envelope["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result = %#v, want object", envelope["result"])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
}
|
||||
flags := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
pipelineID := flags.String("pipeline", "", "pipeline id")
|
||||
bundlePath := flags.String("bundle", "", "source-root-relative bundle path")
|
||||
formatFlag := addFormatFlag(flags)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
@@ -28,7 +31,17 @@ func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
if !ok {
|
||||
return exitUsage
|
||||
}
|
||||
if err := app.Inspect(ctx, app.InspectOptions{Path: path, Stdout: stdout, OutputFormat: format}); err != nil {
|
||||
if !validateInspectModeOK(stderr, "inspect", path, *configPath, *pipelineID, *bundlePath) {
|
||||
return exitUsage
|
||||
}
|
||||
if err := app.Inspect(ctx, app.InspectOptions{
|
||||
Path: path,
|
||||
ConfigPath: *configPath,
|
||||
PipelineID: *pipelineID,
|
||||
BundlePath: *bundlePath,
|
||||
Stdout: stdout,
|
||||
OutputFormat: format,
|
||||
}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
@@ -37,10 +50,15 @@ func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
func printInspectHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor inspect [--format text|json] <path>
|
||||
distributor inspect --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config file for configured source inspection
|
||||
--pipeline <id> Pipeline id to inspect in config mode
|
||||
--bundle <path> Source-root-relative bundle path to inspect
|
||||
--format text|json Output format
|
||||
|
||||
Print a normalized summary of local source bundles.
|
||||
Print a normalized summary of local source bundles or a configured pipeline
|
||||
source.
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,45 @@ func TestExecuteValidateJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidateConfiguredSource(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "", testutil.BundleOptions{})
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"validate", "--config", configPath, "--pipeline", "reports"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
if got, want := stdout.String(), "Validated 1 bundle(s) for pipeline reports source local\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidateConfiguredSourceJSON(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{ID: "reports.daily"})
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"validate", "--config", configPath, "--pipeline", "reports", "--bundle", "daily", "--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"] != "validate" || envelope["ok"] != true {
|
||||
t.Fatalf("envelope = %#v, want validate ok", envelope)
|
||||
}
|
||||
result := envelopeResult(t, envelope)
|
||||
if result["pipeline_id"] != "reports" || result["source_backend"] != "local" || result["bundle_count"] != float64(1) {
|
||||
t.Fatalf("result = %#v, want configured source metadata", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidateArgs(t *testing.T) {
|
||||
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
||||
tests := []struct {
|
||||
@@ -166,6 +205,24 @@ func TestExecuteValidateArgs(t *testing.T) {
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "accepts at most one path",
|
||||
},
|
||||
{
|
||||
name: "path plus config",
|
||||
args: []string{"validate", "--config", "config.yml", "--pipeline", "reports", validPath},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "does not accept a local path",
|
||||
},
|
||||
{
|
||||
name: "pipeline without config",
|
||||
args: []string{"validate", "--pipeline", "reports"},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "requires --config",
|
||||
},
|
||||
{
|
||||
name: "config without pipeline",
|
||||
args: []string{"validate", "--config", "config.yml"},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "requires --pipeline",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -223,6 +280,30 @@ func TestExecuteInspectJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInspectConfiguredSource(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
destinationRoot := t.TempDir()
|
||||
testutil.WriteSourceBundle(t, sourceRoot, "daily", testutil.BundleOptions{ID: "reports.daily"})
|
||||
configPath := testutil.WriteMinimalLocalConfig(t, sourceRoot, destinationRoot)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := Execute(context.Background(), []string{"inspect", "--config", configPath, "--pipeline", "reports"}, &stdout, &stderr)
|
||||
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Pipeline: reports",
|
||||
"Source: local",
|
||||
"path=daily",
|
||||
"id=reports.daily",
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("stdout = %q, want substring %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInspectArgs(t *testing.T) {
|
||||
validPath := filepath.Join("..", "bundle", "testdata", "valid_bundle")
|
||||
tests := []struct {
|
||||
@@ -250,6 +331,24 @@ func TestExecuteInspectArgs(t *testing.T) {
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "accepts at most one path",
|
||||
},
|
||||
{
|
||||
name: "path plus config",
|
||||
args: []string{"inspect", "--config", "config.yml", "--pipeline", "reports", validPath},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "does not accept a local path",
|
||||
},
|
||||
{
|
||||
name: "pipeline without config",
|
||||
args: []string{"inspect", "--pipeline", "reports"},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "requires --config",
|
||||
},
|
||||
{
|
||||
name: "config without pipeline",
|
||||
args: []string{"inspect", "--config", "config.yml"},
|
||||
wantCode: exitUsage,
|
||||
wantStderr: "requires --pipeline",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
26
internal/cli/source_mode.go
Normal file
26
internal/cli/source_mode.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func validateInspectModeOK(stderr io.Writer, command, path, configPath, pipelineID, bundlePath string) bool {
|
||||
configMode := configPath != "" || pipelineID != "" || bundlePath != ""
|
||||
if !configMode {
|
||||
return true
|
||||
}
|
||||
if path != "" {
|
||||
fmt.Fprintf(stderr, "distributor: %s does not accept a local path with --config, --pipeline, or --bundle\n", command)
|
||||
return false
|
||||
}
|
||||
if configPath == "" {
|
||||
fmt.Fprintf(stderr, "distributor: %s requires --config when --pipeline or --bundle is set\n", command)
|
||||
return false
|
||||
}
|
||||
if pipelineID == "" {
|
||||
fmt.Fprintf(stderr, "distributor: %s requires --pipeline in config mode\n", command)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -16,6 +16,9 @@ func validateCommand(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
}
|
||||
flags := flag.NewFlagSet("validate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", "", "path to config file")
|
||||
pipelineID := flags.String("pipeline", "", "pipeline id")
|
||||
bundlePath := flags.String("bundle", "", "source-root-relative bundle path")
|
||||
formatFlag := addFormatFlag(flags)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
@@ -28,7 +31,17 @@ func validateCommand(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
if !ok {
|
||||
return exitUsage
|
||||
}
|
||||
if err := app.Validate(ctx, app.ValidateOptions{Path: path, Stdout: stdout, OutputFormat: format}); err != nil {
|
||||
if !validateInspectModeOK(stderr, "validate", path, *configPath, *pipelineID, *bundlePath) {
|
||||
return exitUsage
|
||||
}
|
||||
if err := app.Validate(ctx, app.ValidateOptions{
|
||||
Path: path,
|
||||
ConfigPath: *configPath,
|
||||
PipelineID: *pipelineID,
|
||||
BundlePath: *bundlePath,
|
||||
Stdout: stdout,
|
||||
OutputFormat: format,
|
||||
}); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return exitOK
|
||||
@@ -37,10 +50,15 @@ func validateCommand(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
func printValidateHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Usage:
|
||||
distributor validate [--format text|json] <path>
|
||||
distributor validate --config <path> --pipeline <id> [--bundle <path>] [--format text|json]
|
||||
|
||||
Options:
|
||||
--config <path> Path to config file for configured source validation
|
||||
--pipeline <id> Pipeline id to validate in config mode
|
||||
--bundle <path> Source-root-relative bundle path to validate
|
||||
--format text|json Output format
|
||||
|
||||
Validate a local source bundle directory or a tree containing source bundles.
|
||||
Validate a local source bundle directory, a local source bundle tree, or a
|
||||
configured pipeline source.
|
||||
`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user