Add JSON output format for CLI commands

This commit is contained in:
2026-06-01 20:44:27 +00:00
parent 0382978af0
commit e51bc28b05
13 changed files with 849 additions and 48 deletions

View File

@@ -10,11 +10,15 @@ import (
)
type InspectOptions struct {
Path string
Stdout io.Writer
Path string
Stdout io.Writer
OutputFormat OutputFormat
}
func Inspect(ctx context.Context, options InspectOptions) error {
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
return err
}
if options.Path == "" {
return fmt.Errorf("inspect command requires a path")
}
@@ -26,9 +30,60 @@ func Inspect(ctx context.Context, options InspectOptions) error {
if err != nil {
return err
}
if IsJSONOutput(options.OutputFormat) {
return WriteJSONEnvelope(options.Stdout, "inspect", true, nil, inspectResultFromBundles(bundles), nil)
}
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 {
if w == nil {
return nil

76
internal/app/output.go Normal file
View 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)
}

View File

@@ -15,14 +15,18 @@ import (
)
type RunOptions struct {
ConfigPath string
DryRun bool
Force bool
Stdout io.Writer
Notifier notify.Notifier
ConfigPath string
DryRun bool
Force bool
Stdout io.Writer
OutputFormat OutputFormat
Notifier notify.Notifier
}
func Run(ctx context.Context, options RunOptions) error {
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
@@ -49,28 +53,41 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
if notifier == nil {
notifier = notify.Noop{}
}
jsonOutput := IsJSONOutput(options.OutputFormat)
summary := runSummary{dryRun: options.DryRun}
result := runResult{
DryRun: options.DryRun,
Pipelines: []runPipelineResult{},
Actions: []runActionResult{},
}
var warnings []OutputWarning
var failures runFailures
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
if err != nil {
return err
}
if options.Stdout != nil {
if err := writeSecretConflictWarnings(options.Stdout, secretLoad.Conflicts); err != nil {
secretWarnings := secretConflictWarnings(secretLoad.Conflicts)
if jsonOutput {
warnings = append(warnings, secretWarnings...)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, secretWarnings); err != nil {
return err
}
}
backends := provider(secretLoad.Environment)
backends.readOnlyKnownHosts = options.DryRun
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 {
return err
}
}
for _, pipeline := range cfg.Pipelines {
if options.Stdout != nil {
if err := writeSSHWarnings(options.Stdout, pipeline); err != nil {
pipelineWarnings := sshWarnings(pipeline)
if jsonOutput {
warnings = append(warnings, pipelineWarnings...)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, pipelineWarnings); err != nil {
return err
}
}
@@ -83,7 +100,13 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
closeBackend(sourceBackend)
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 {
closeBackend(sourceBackend)
return err
@@ -95,7 +118,9 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
if err != nil {
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
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)
}
continue
@@ -122,10 +147,23 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
Force: options.Force,
}
plan, err := publish.Build(ctx, req)
if err != nil && plan.DestinationID == "" {
plan = publish.Plan{DestinationID: destination.ID, BundlePath: sourceBundle.RootRelativePath}
if err != nil {
if plan.PipelineID == "" {
plan.PipelineID = pipeline.ID
}
if plan.DestinationID == "" {
plan.DestinationID = destination.ID
}
if plan.BundleID == "" {
plan.BundleID = sourceBundle.Manifest.ID
}
if plan.BundlePath == "" {
plan.BundlePath = sourceBundle.RootRelativePath
}
}
if options.Stdout != nil {
if jsonOutput {
result.Actions = append(result.Actions, runActionFromPlan(destination.Backend, plan, err))
} else if options.Stdout != nil {
writePlanLine(options.Stdout, destination.Backend, plan, err)
}
if err != nil {
@@ -156,7 +194,12 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
}
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 {
return err
}
@@ -212,6 +255,14 @@ func outputSummary(outputs []publish.Output) string {
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 {
if len(destinations) == 0 {
return "none"
@@ -224,25 +275,44 @@ func destinationSummary(destinations []config.Destination) string {
}
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 {
if _, err := fmt.Fprintf(w, "Warning: secret %s ignored because the real environment already has that variable\n", conflict.Name); err != nil {
return err
}
warnings = append(warnings, OutputWarning{
Message: fmt.Sprintf("secret %s ignored because the real environment already has that variable", conflict.Name),
})
}
return nil
return warnings
}
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 _, err := fmt.Fprintf(w, "Warning: pipeline=%s source host_key_policy=off disables SSH host key checking\n", pipeline.ID); err != nil {
return err
}
warnings = append(warnings, OutputWarning{
Message: fmt.Sprintf("pipeline=%s source host_key_policy=off disables SSH host key checking", pipeline.ID),
})
}
for _, destination := range pipeline.Destinations {
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 {
return err
}
warnings = append(warnings, OutputWarning{
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
@@ -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 {
dryRun bool
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)
}
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 {
pipelineID string
destinationID string
@@ -343,6 +531,28 @@ func (f runFailures) Error() string {
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 {
errs := make([]error, 0, len(f.items))
for _, item := range f.items {

View File

@@ -6,14 +6,19 @@ import (
"io"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
type ValidateOptions struct {
Path string
Stdout io.Writer
Path string
Stdout io.Writer
OutputFormat OutputFormat
}
func Validate(ctx context.Context, options ValidateOptions) error {
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
return err
}
if options.Path == "" {
return fmt.Errorf("validate command requires a path")
}
@@ -25,8 +30,35 @@ func Validate(ctx context.Context, options ValidateOptions) error {
if err != nil {
return err
}
if IsJSONOutput(options.OutputFormat) {
return WriteJSONEnvelope(options.Stdout, "validate", true, nil, validateResultFromBundles(bundles), nil)
}
if options.Stdout != nil {
_, err = fmt.Fprintf(options.Stdout, "Validated %d bundle(s)\n", len(bundles))
}
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
}