Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ lint: golangci-lint ## Run golangci linters.
build: tidy fmt vet ## Build CLI binary.
CGO_ENABLED=0 go build -ldflags="-s -w -X main.VERSION=$(VERSION_DEV)" -o ./bin/flux-schema ./cmd/flux-schema/

.PHONY: install
install: test lint build ## Test, lint, build and copy the binary to GOBIN.
cp bin/flux-schema $(GOBIN)

.PHONY: run
run: build ## Run CLI binary.
./bin/flux-schema $(GO_RUN_ARGS)
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ Flux CLI plugin for Kubernetes schema extraction and manifests validation.
- `flux-schema validate [paths...]`: Validate Kubernetes manifests against JSON Schemas
- `--schema-location`: Template URL or file path for schemas (repeatable, tried in order); use `default` for the Flux catalog
- `--skip-missing-schemas`: Skip documents for which no schema can be found
- `-v, --verbose`: Print a line for every document, including valid and skipped
- `--skip-kind`: Skip documents matching `Kind` or `apiVersion/Kind` (repeatable)
- `-v, --verbose`: Print a line for every document, including valid and skipped ones
- `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`)
- `-a, --output-archive`: Path to write a gzipped tar archive of JSON Schema files to
Expand All @@ -45,13 +46,14 @@ To validate against your own schemas, pass `--schema-location` with a Go templat

```shell
flux-schema validate ./manifests \
--skip-missing-schemas \
--schema-location './schemas/{{.Kind}}-{{.GroupPrefix}}-{{.Version}}.json'
```

Template variables are `.Group`, `.GroupPrefix`, `.Kind`, and `.Version`.

The flag is repeatable and locations are tried in order — the first match wins. Pass the
literal value `default` to include the flux-schema catalog alongside your own schemas:
The `--schema-location` flag is repeatable and locations are tried in order — the first match wins.
Pass the literal value `default` to include the flux-schema catalog alongside your own schemas:

```shell
flux-schema validate ./manifests \
Expand All @@ -60,10 +62,12 @@ flux-schema validate ./manifests \
--schema-location './schemas/{{.Kind}}-{{.GroupPrefix}}-{{.Version}}.json'
```

Manifests can also be piped via `/dev/stdin`:
Manifests can also be piped via `/dev/stdin` and certain documents skipped with `--skip-kind`:

```shell
kustomize build . | flux-schema validate /dev/stdin
kustomize build . | flux-schema validate /dev/stdin \
--skip-kind 'v1/Secret' \
--skip-kind 'source.toolkit.fluxcd.io/v1/ExternalArtifact'
```

Output example with validation errors:
Expand All @@ -77,14 +81,15 @@ manifests/sources.yaml - Bucket/apps/s3-data is invalid: schema validation faile
manifests/sources.yaml - OCIRepository/apps/podinfo is invalid: YAML parse failed
- line 18: key "app.kubernetes.io/name" already set in map
manifests/sources.yaml - HelmChart/apps/redis is valid
Summary: 3 resources found in 1 file - Valid: 1, Invalid: 2, Skipped: 0
manifests/sources.yaml - Secret/apps/auth-sops is skipped: kind skipped
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.

Validation is strict by default:

- YAML documents with duplicate keys are rejected.
- YAML documents with duplicate keys are rejected matching Flux behavior.
- Documents missing both `metadata.name` and `metadata.generateName` are flagged as invalid
matching Kubernetes API behavior.
- Schemas produced by `flux-schema extract crd` close objects with `additionalProperties: false`,
Expand Down
11 changes: 10 additions & 1 deletion cmd/flux-schema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,20 @@ var validateCmd = &cobra.Command{
# Read manifests from a pipe
kustomize build . | flux-schema validate /dev/stdin \
--schema-location default \
--schema-location './schemas/{{.Group}}/{{.Kind}}_{{.Version}}.json'`,
--schema-location './schemas/{{.Group}}/{{.Kind}}_{{.Version}}.json'

# Skip specific kinds by Kind or apiVersion/Kind
flux-schema validate ./manifests \
--skip-kind Secret \
--skip-kind source.toolkit.fluxcd.io/v1/GitRepository`,
Args: cobra.MinimumNArgs(1),
RunE: validateCmdRun,
}

type validateFlags struct {
schemaLocations []string
skipMissingSchemas bool
skipKinds []string
verbose bool
}

Expand All @@ -61,6 +67,8 @@ func init() {
"template URL or file path for schemas (repeatable); use 'default' for the built-in catalog")
validateCmd.Flags().BoolVar(&validateArgs.skipMissingSchemas, "skip-missing-schemas", false,
"skip documents for which no schema can be found instead of failing")
validateCmd.Flags().StringArrayVar(&validateArgs.skipKinds, "skip-kind", nil,
"skip documents matching Kind or apiVersion/Kind (repeatable)")
validateCmd.Flags().BoolVarP(&validateArgs.verbose, "verbose", "v", false,
"print a line for every document, including valid and skipped")
rootCmd.AddCommand(validateCmd)
Expand All @@ -81,6 +89,7 @@ func validateCmdRun(cmd *cobra.Command, args []string) error {
v, err := validator.New(validator.Options{
SchemaLocations: locations,
SkipMissingSchemas: validateArgs.skipMissingSchemas,
SkipKinds: validateArgs.skipKinds,
HTTPTimeout: rootArgs.timeout,
})
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions cmd/flux-schema/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,47 @@ func TestValidateCmd_InvalidMetadataFixtures(t *testing.T) {
g.Expect(out).To(ContainSubstring("Summary: 3 resources found in 1 file - Valid: 0, Invalid: 3, Skipped: 0"))
}

// TestValidateCmd_SkipKind exercises all three accepted pattern shapes against
// the real Flux fixtures: a bare Kind, an apiVersion/Kind, and a
// group/version/Kind. Each doc that matches is skipped instead of being
// validated, with a short-circuit line reading "... is skipped: kind skipped".
func TestValidateCmd_SkipKind(t *testing.T) {
g := NewWithT(t)

reconcilersPath := "./testdata/validate/manifests/valid-reconcilers.yaml"
sourcesPath := "./testdata/validate/manifests/valid-sources.yaml"

out, err := executeCommand([]string{
"validate",
reconcilersPath,
sourcesPath,
"--schema-location", "./testdata/validate/schemas/{{ .Group }}/{{ .Kind }}_{{ .Version }}.json",
"--skip-kind", "Secret",
"--skip-kind", "source.toolkit.fluxcd.io/v1/GitRepository",
"--skip-kind", "helm.toolkit.fluxcd.io/v2/HelmRelease",
"--verbose",
})
g.Expect(err).ToNot(HaveOccurred())

g.Expect(out).To(ContainSubstring(sourcesPath + " - Secret/default/minio-bucket-secret is skipped: kind skipped"))
g.Expect(out).To(ContainSubstring(" - GitRepository/"))
g.Expect(out).To(ContainSubstring("is skipped: kind skipped"))
g.Expect(out).To(ContainSubstring(" - HelmRelease/"))
}

func TestValidateCmd_SkipKind_Invalid(t *testing.T) {
g := NewWithT(t)
manifestDir := t.TempDir()
writeManifest(t, manifestDir, "ok.yaml", validWidget)

_, err := executeCommand([]string{
"validate", manifestDir,
"--schema-location", filepath.Join(t.TempDir(), "{{.Kind}}-{{.GroupPrefix}}-{{.Version}}.json"),
"--skip-kind", "v1/",
})
g.Expect(err).To(MatchError(ContainSubstring("skip kind pattern")))
}

func TestExpandSchemaLocations(t *testing.T) {
g := NewWithT(t)

Expand Down
72 changes: 72 additions & 0 deletions internal/validator/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2026 The Flux Authors
// SPDX-License-Identifier: Apache-2.0

// Package validator validates Kubernetes YAML manifests against JSON
// Schemas resolved from one or more schema location templates.
//
// # Entry points
//
// New returns a Validator configured from Options. ValidateSources walks
// files and directories and streams Results over a channel; ValidateBytes
// validates an in-memory payload and returns the Results slice.
//
// # YAML handling
//
// Multi-document streams are split on "\n---" boundaries. Documents that
// contain only comments or whitespace are dropped entirely rather than
// surfaced as skipped, keeping user-visible document numbering aligned
// with the real resources in each file.
//
// YAML is decoded in strict mode, so duplicate keys fail the document.
// When strict decoding fails, a lenient re-parse recovers apiVersion,
// kind, namespace, and name for the Result so callers can still render
// a meaningful identifier for the failing document.
//
// # Admission and schema checks
//
// For every decoded document the pipeline runs, in order:
//
// 1. SkipKinds matching — a pattern of "Kind" or "apiVersion/Kind"
// short-circuits validation with StatusSkipped, before the admission
// rule so encrypted or sealed manifests that omit metadata.name are
// still skipped cleanly.
// 2. apiVersion/kind presence — missing either field fails the document;
// Options.SkipMissingSchemas downgrades this to StatusSkipped.
// 3. Admission rule — metadata.name or metadata.generateName must be
// set, matching kube-apiserver behavior.
// 4. Schema resolution — each location template is rendered with the
// document's group/version/kind and the first matching schema is
// compiled and cached. 404 / ENOENT on every location fails the
// document unless Options.SkipMissingSchemas is set.
// 5. JSON Schema validation — the compiled schema is run against the
// decoded document; per-field violations are returned as a flat list
// of ValidationError with JSON Pointer paths.
//
// Schemas produced by the extractor package close objects with
// additionalProperties: false, so undocumented fields under spec fail
// validation.
//
// # Schema resolution and caching
//
// SchemaLoader renders each location template with the document's
// group/version/kind and loads from http(s) URLs (via retryablehttp,
// honoring Options.HTTPTimeout) or the local filesystem. Each rendered
// location is fetched, parsed, and compiled at most once per Validator
// lifetime, and the compiled *jsonschema.Schema is reused across
// documents. Compilation uses JSON Schema Draft 2020-12 with the
// Kubernetes string formats (duration, date, datetime/date-time, time)
// registered on the compiler — including duration units kube-apiserver
// accepts but Go's time.ParseDuration rejects (e.g. "2w", "3d").
//
// # Concurrency and streaming
//
// ValidateSources walks sources sequentially on one producer goroutine
// and validates documents in parallel on a pool of Options.Workers
// workers. Results arrive on the returned channel in completion order,
// which is non-deterministic; each Result carries Source and DocIndex
// so callers can reorder. After every real Result for a source has been
// pushed, a synthetic Result with Final=true is emitted for that source
// so consumers can flush per-source state mid-stream instead of
// buffering until end-of-stream. The channel is closed once all
// documents and sentinels have been delivered.
package validator
82 changes: 66 additions & 16 deletions internal/validator/validator.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
// Copyright 2026 The Flux Authors
// SPDX-License-Identifier: Apache-2.0

// Package validator validates Kubernetes YAML manifests against JSON
// Schemas resolved from one or more --schema-location templates.
//
// Validation is strict by default: YAML duplicate keys are rejected,
// documents missing both metadata.name and metadata.generateName are
// flagged (kube-apiserver admission rule), and schemas produced by
// flux-schema extract enforce additionalProperties: false.
package validator

import (
Expand All @@ -17,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"text/template"
Expand Down Expand Up @@ -98,6 +92,7 @@ func (r Result) Identifier() string {
type Options struct {
SchemaLocations []string
SkipMissingSchemas bool
SkipKinds []string
HTTPClient *retryablehttp.Client
HTTPTimeout time.Duration
Workers int
Expand All @@ -106,8 +101,44 @@ type Options struct {
// Validator resolves and applies JSON Schemas to Kubernetes manifests.
// It is safe for concurrent use by multiple goroutines.
type Validator struct {
opts Options
loader *SchemaLoader
opts Options
loader *SchemaLoader
skipKinds []skipKindMatcher
}

// skipKindMatcher matches a document by Kind, optionally scoped to an
// apiVersion. An empty apiVersion matches any group/version.
type skipKindMatcher struct {
apiVersion string
kind string
}

// parseSkipKind parses a SkipKinds pattern. Accepted shapes:
//
// Kind e.g. "Secret"
// apiVersion/Kind e.g. "v1/Secret", "source.toolkit.fluxcd.io/v1/GitRepository"
func parseSkipKind(s string) (skipKindMatcher, error) {
s = strings.TrimSpace(s)
if s == "" {
return skipKindMatcher{}, errors.New("skip kind pattern must not be empty")
}
parts := strings.Split(s, "/")
if slices.Contains(parts, "") {
return skipKindMatcher{}, fmt.Errorf("skip kind pattern %q: segments must not be empty", s)
}
kind := parts[len(parts)-1]
var apiVersion string
if len(parts) > 1 {
apiVersion = strings.Join(parts[:len(parts)-1], "/")
}
return skipKindMatcher{apiVersion: apiVersion, kind: kind}, nil
}

func (m skipKindMatcher) matches(apiVersion, kind string) bool {
if m.kind != kind {
return false
}
return m.apiVersion == "" || m.apiVersion == apiVersion
}

// New returns a Validator configured from opts. Each location template is
Expand Down Expand Up @@ -139,9 +170,19 @@ func New(opts Options) (*Validator, error) {
opts.HTTPClient = c
}

skipKinds := make([]skipKindMatcher, 0, len(opts.SkipKinds))
for _, s := range opts.SkipKinds {
m, err := parseSkipKind(s)
if err != nil {
return nil, err
}
skipKinds = append(skipKinds, m)
}

return &Validator{
opts: opts,
loader: NewSchemaLoader(templates, opts.HTTPClient, opts.HTTPTimeout),
opts: opts,
loader: NewSchemaLoader(templates, opts.HTTPClient, opts.HTTPTimeout),
skipKinds: skipKinds,
}, nil
}

Expand Down Expand Up @@ -367,8 +408,8 @@ func (v *Validator) validateDoc(ctx context.Context, source string, idx int, raw
return r, true
}
// missingSchemaStatus picks between StatusSkipped and StatusInvalid for
// "we can't resolve a schema for this document" cases, honoring the
// --skip-missing-schemas flag.
// "we can't resolve a schema for this document" cases, honoring
// Options.SkipMissingSchemas.
missingSchemaStatus := func() Status {
if v.opts.SkipMissingSchemas {
return StatusSkipped
Expand All @@ -378,9 +419,9 @@ func (v *Validator) validateDoc(ctx context.Context, source string, idx int, raw

var doc map[string]any
if err := yaml.UnmarshalStrict(raw, &doc); err != nil {
// Lenient re-parse so the CLI line can still show Kind/Namespace/Name
// for a doc that failed admission (e.g. duplicate keys), rather than
// the anonymous "/#1".
// Lenient re-parse so callers can still render Kind/Namespace/Name
// for a doc that failed admission (e.g. duplicate keys), rather
// than the anonymous "/#1".
var lenient map[string]any
if yaml.Unmarshal(raw, &lenient) == nil && lenient != nil {
r.APIVersion, r.Kind, r.Namespace, r.Name = extractIdentity(lenient)
Expand All @@ -402,6 +443,15 @@ func (v *Validator) validateDoc(ctx context.Context, source string, idx int, raw
name, hasIdentity := computeName(metadata, idx)
r.Name = name

// SkipKinds matching runs before admission and schema checks so a
// kind-only entry (e.g. "Secret") also covers sealed/encrypted manifests
// that would otherwise fail the name/generateName rule.
for _, m := range v.skipKinds {
if m.matches(r.APIVersion, r.Kind) {
return settle(StatusSkipped, "kind skipped")
}
}

if r.APIVersion == "" || r.Kind == "" {
return settle(missingSchemaStatus(), "document missing apiVersion/kind")
}
Expand Down
Loading
Loading