Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Flux CLI plugin for Kubernetes schema extraction and manifests validation.
- `--concurrent`: Number of concurrent workers (default 8)
- `--insecure-skip-tls-verify`: Disable TLS certificate verification when fetching schemas over HTTPS
- `-v, --verbose`: Print a line for every document, including valid and skipped ones
- `-o, --output`: Output format, one of `text|json|yaml` (default: `text`)
- `--config`: Path to a YAML file supplying default values for validate flags (env: `FLUX_SCHEMA_CONFIG`)
- `flux-schema extract crd [files...]`: Extract JSON Schema from Kubernetes CRD YAMLs
- `-d, --output-dir`: Directory to write JSON Schema files to (mutually exclusive with `--output-archive`)
Expand Down Expand Up @@ -85,12 +86,12 @@ kustomize build . | flux-schema validate \
Output example with validation errors:

```
manifests/sources.yaml - Bucket/apps/s3-data is invalid: schema validation failed
manifests/sources.yaml - Bucket/apps/s3-data is invalid: schema violation
- /spec: missing property 'bucketName'
- /spec/interval: got number, want string
- /spec/secretRef/name: got object, want string
- /spec: additional properties 'force' not allowed
manifests/sources.yaml - OCIRepository/apps/podinfo is invalid: YAML parse failed
manifests/sources.yaml - OCIRepository/apps/podinfo is invalid: yaml parse error
- line 18: key "app.kubernetes.io/name" already set in map
manifests/sources.yaml - HelmChart/apps/redis is valid
manifests/sources.yaml - Secret/apps/auth-sops is skipped: kind skipped
Expand All @@ -99,6 +100,20 @@ Summary: 4 resources found in 1 file - Valid: 1, Invalid: 2, Skipped: 1

A non-zero exit code is returned when any document is invalid or errored.

#### Structured output

For CI pipelines and tooling, pass `-o json` (or `-o yaml`) to emit a
machine-readable report instead of text:

```shell
flux-schema validate ./manifests -o json | jq '.report.summary'
```

See the [validation report reference](docs/report/README.md) for the full
envelope shape, the `reason` enum, and an example covering every result
type. The report is versioned by a published
[JSON Schema](docs/report/schema-1.0.0.json).

#### Validation rules

- YAML documents with duplicate keys are rejected matching Flux behavior.
Expand Down Expand Up @@ -137,6 +152,7 @@ validate:
fail-fast: false
concurrent: 8
insecure-skip-tls-verify: false
output: text
```

Rules:
Expand Down
16 changes: 13 additions & 3 deletions cmd/flux-schema/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type validateConfig struct {
FailFast bool `json:"fail-fast,omitempty"`
Concurrent *int `json:"concurrent,omitempty"`
InsecureSkipTLSVerify bool `json:"insecure-skip-tls-verify,omitempty"`
Output string `json:"output,omitempty"`
}

func loadConfigFile(path string) (*configFile, error) {
Expand All @@ -50,10 +51,13 @@ func loadConfigFile(path string) (*configFile, error) {

// applyValidateConfig copies cfg values into args for flags not set on the CLI,
// giving CLI > config > defaults precedence. Nil cfg is a no-op so callers can
// pass cfg.Validate directly without a nil check.
func applyValidateConfig(cmd *cobra.Command, cfg *validateConfig, args *validateFlags) {
// pass cfg.Validate directly without a nil check. Returns an error when a
// config value fails the same validation the CLI flag would apply (e.g. an
// invalid output format), so bad config is caught up-front rather than
// silently ignored.
func applyValidateConfig(cmd *cobra.Command, cfg *validateConfig, args *validateFlags) error {
if cfg == nil {
return
return nil
}
flags := cmd.Flags()

Expand All @@ -78,4 +82,10 @@ func applyValidateConfig(cmd *cobra.Command, cfg *validateConfig, args *validate
if !flags.Changed("insecure-skip-tls-verify") {
args.insecureSkipTLSVerify = cfg.InsecureSkipTLSVerify
}
if cfg.Output != "" && !flags.Changed("output") {
if err := args.output.Set(cfg.Output); err != nil {
return fmt.Errorf("config output: %w", err)
}
}
return nil
}
2 changes: 1 addition & 1 deletion cmd/flux-schema/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func resetCmdArgs() {
versionArgs = versionFlags{output: "text"}
extractCRDArgs = extractCRDFlags{ExtractOutput: flags.NewExtractOutput()}
extractK8sArgs = extractK8sFlags{ExtractOutput: flags.NewExtractOutput()}
validateArgs = validateFlags{concurrent: validator.DefaultWorkers}
validateArgs = validateFlags{concurrent: validator.DefaultWorkers, output: "text"}

// pflag.Flag.Changed persists across Execute calls on the shared rootCmd,
// which breaks MarkFlagRequired validation in subsequent tests. Clear it
Expand Down
162 changes: 117 additions & 45 deletions cmd/flux-schema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ package main

import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"sort"
"strings"
"time"

"github.com/spf13/cobra"
"sigs.k8s.io/yaml"

"github.com/fluxcd/flux-schema/internal/flags"
"github.com/fluxcd/flux-schema/internal/validator"
)

Expand Down Expand Up @@ -58,10 +62,12 @@ type validateFlags struct {
concurrent int
insecureSkipTLSVerify bool
configFile string
output flags.Output
}

var validateArgs = validateFlags{
concurrent: validator.DefaultWorkers,
output: "text",
}

func init() {
Expand All @@ -83,52 +89,23 @@ func init() {
"path to a YAML file supplying default values for validate flags "+
"(env: "+envConfigFile+")")
_ = validateCmd.MarkFlagFilename("config", "yaml", "yml")
validateCmd.Flags().VarP(&validateArgs.output, "output", "o", validateArgs.output.Description())
rootCmd.AddCommand(validateCmd)
}

func validateCmdRun(cmd *cobra.Command, args []string) error {
configPath := validateArgs.configFile
if configPath == "" {
configPath = os.Getenv(envConfigFile)
}
if configPath != "" {
cfg, err := loadConfigFile(configPath)
if err != nil {
return err
}
applyValidateConfig(cmd, cfg.Validate, &validateArgs)
if err := loadValidateConfig(cmd); err != nil {
return err
}

inputs, err := resolveStdinArgs(args)
if err != nil {
return err
}

locations := validateArgs.schemaLocations
if len(locations) == 0 {
locations = []string{validator.DefaultSchemaLocation}
} else {
expanded, lerr := expandSchemaLocations(locations)
if lerr != nil {
return lerr
}
locations = expanded
}

if validateArgs.concurrent < 1 {
return fmt.Errorf("--concurrent must be >= 1, got %d", validateArgs.concurrent)
}

opts := validator.Options{
SchemaLocations: locations,
SkipMissingSchemas: validateArgs.skipMissingSchemas,
SkipKinds: validateArgs.skipKinds,
HTTPTimeout: rootArgs.timeout,
Workers: validateArgs.concurrent,
InsecureSkipTLSVerify: validateArgs.insecureSkipTLSVerify,
}
if slices.Contains(inputs, stdinLabel) {
opts.Stdin = stdinReader
opts, err := buildValidatorOptions(inputs)
if err != nil {
return err
}
v, err := validator.New(opts)
if err != nil {
Expand All @@ -139,9 +116,23 @@ func validateCmdRun(cmd *cobra.Command, args []string) error {
defer cancel()

stdinOnly := len(inputs) == 1 && inputs[0] == stdinLabel
mode := validateArgs.output.String()

files := make(map[string]struct{})
var nValid, nInvalid, nSkipped int
var collected []validator.Result

// Text mode streams per-result; structured modes buffer so the envelope
// can carry the full summary ahead of results[].
emit := func(r validator.Result) {
if mode == "text" {
if shouldPrint(r.Status, validateArgs.verbose) {
writeResult(cmd, r)
}
return
}
collected = append(collected, r)
}

// Reorder buffer: workers complete documents out of order, but we want
// deterministic (source, docIndex) output. For each source we track the
Expand Down Expand Up @@ -169,9 +160,7 @@ func validateCmdRun(cmd *cobra.Command, args []string) error {
if !ok {
return
}
if shouldPrint(r.Status, validateArgs.verbose) {
writeResult(cmd, r)
}
emit(r)
delete(buf.pending, buf.nextIdx)
buf.nextIdx++
}
Expand All @@ -191,10 +180,7 @@ func validateCmdRun(cmd *cobra.Command, args []string) error {
}
sort.Ints(indices)
for _, i := range indices {
r := buf.pending[i]
if shouldPrint(r.Status, validateArgs.verbose) {
writeResult(cmd, r)
}
emit(buf.pending[i])
}
buf.pending = nil
}
Expand Down Expand Up @@ -253,7 +239,20 @@ func validateCmdRun(cmd *cobra.Command, args []string) error {
flushRemaining(src)
}

writeSummary(cmd, len(files), nValid+nInvalid+nSkipped, nValid, nInvalid, nSkipped, stdinOnly)
if mode != "text" {
summary := validator.ReportSummary{
Total: nValid + nInvalid + nSkipped,
Valid: nValid,
Invalid: nInvalid,
Skipped: nSkipped,
}
report := validator.NewReport("flux-schema/"+VERSION, time.Now(), collected, summary)
if err := writeReport(cmd, mode, report); err != nil {
return err
}
} else {
writeSummary(cmd, len(files), nValid+nInvalid+nSkipped, nValid, nInvalid, nSkipped, stdinOnly)
}

if nInvalid > 0 {
// Summary line already communicates the failure; exit non-zero
Expand All @@ -263,6 +262,31 @@ func validateCmdRun(cmd *cobra.Command, args []string) error {
return nil
}

// writeReport streams report to cmd's output as JSON or YAML. The `$schema`
// key is JSON-only: it points at a JSON Schema document and carries no
// meaning for YAML consumers, so we drop it in YAML mode.
func writeReport(cmd *cobra.Command, mode string, report validator.Report) error {
switch mode {
case "json":
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return fmt.Errorf("marshal report: %w", err)
}
return nil
case "yaml":
report.Schema = ""
data, err := yaml.Marshal(report)
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
cmd.Print(string(data))
return nil
default:
return fmt.Errorf("unsupported output format %q", mode)
}
}

// expandSchemaLocations normalizes each --schema-location value so callers can
// pass either a full Go template or a bare path/URL:
//
Expand Down Expand Up @@ -327,8 +351,8 @@ func writeResult(cmd *cobra.Command, r validator.Result) {
case validator.StatusSkipped:
verb = "is skipped"
}
if r.Message != "" {
cmd.Printf("%s - %s %s: %s\n", r.Source, r.Identifier(), verb, r.Message)
if r.Reason != validator.ReasonNone {
cmd.Printf("%s - %s %s: %s\n", r.Source, r.Identifier(), verb, r.Reason)
} else {
cmd.Printf("%s - %s %s\n", r.Source, r.Identifier(), verb)
}
Expand Down Expand Up @@ -359,3 +383,51 @@ func pluralize(word string, n int) string {
}
return word + "s"
}

// loadValidateConfig applies a config file resolved from --config or the
// FLUX_SCHEMA_CONFIG env var; missing path is a no-op.
func loadValidateConfig(cmd *cobra.Command) error {
configPath := validateArgs.configFile
if configPath == "" {
configPath = os.Getenv(envConfigFile)
}
if configPath == "" {
return nil
}
cfg, err := loadConfigFile(configPath)
if err != nil {
return err
}
return applyValidateConfig(cmd, cfg.Validate, &validateArgs)
}

// buildValidatorOptions expands --schema-location values, validates flag
// invariants, and assembles the validator.Options. Stdin is wired in when
// inputs references the stdin sentinel.
func buildValidatorOptions(inputs []string) (validator.Options, error) {
locations := validateArgs.schemaLocations
if len(locations) == 0 {
locations = []string{validator.DefaultSchemaLocation}
} else {
expanded, err := expandSchemaLocations(locations)
if err != nil {
return validator.Options{}, err
}
locations = expanded
}
if validateArgs.concurrent < 1 {
return validator.Options{}, fmt.Errorf("--concurrent must be >= 1, got %d", validateArgs.concurrent)
}
opts := validator.Options{
SchemaLocations: locations,
SkipMissingSchemas: validateArgs.skipMissingSchemas,
SkipKinds: validateArgs.skipKinds,
HTTPTimeout: rootArgs.timeout,
Workers: validateArgs.concurrent,
InsecureSkipTLSVerify: validateArgs.insecureSkipTLSVerify,
}
if slices.Contains(inputs, stdinLabel) {
opts.Stdin = stdinReader
}
return opts, nil
}
Loading
Loading