Add configured source validation and inspection

This commit is contained in:
2026-06-01 21:11:35 +00:00
parent 8b1e5abf68
commit 29fd0e494c
13 changed files with 728 additions and 50 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -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 "."`

View File

@@ -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",

View File

@@ -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") {

View File

@@ -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

View 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),
}}
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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.
`)
}

View File

@@ -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) {

View 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
}

View File

@@ -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.
`)
}