From 6a88c52586d950fc010190d5f8cd68794c2a76f6 Mon Sep 17 00:00:00 2001 From: Wayne Starr Date: Fri, 5 Jun 2026 13:15:39 -0600 Subject: [PATCH 1/4] feat: zarf dev schema-generate from existing values Signed-off-by: Wayne Starr --- .../values/values.schema.json | 185 ++++++++++-------- src/cmd/dev.go | 135 +++++++++++++ src/pkg/packager/layout/assemble.go | 6 +- src/pkg/packager/load/load.go | 12 +- src/pkg/value/generate.go | 121 ++++++++++++ src/pkg/value/generate_test.go | 149 ++++++++++++++ src/pkg/value/schema.go | 21 ++ src/pkg/value/schema_test.go | 24 +++ 8 files changed, 563 insertions(+), 90 deletions(-) create mode 100644 src/pkg/value/generate.go create mode 100644 src/pkg/value/generate_test.go diff --git a/examples/values-templating/values/values.schema.json b/examples/values-templating/values/values.schema.json index f82c7245fd..8300709c7a 100644 --- a/examples/values-templating/values/values.schema.json +++ b/examples/values-templating/values/values.schema.json @@ -1,115 +1,138 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", "description": "Schema for values-templating example package values", "properties": { - "site": { - "type": "object", - "description": "Site configuration and metadata", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "The name of the site you are deploying" - }, - "styles": { - "type": "string", - "description": "CSS styles for the site" - }, - "organization": { - "type": "string", - "description": "The organization providing the site" - }, - "footer": { - "type": "string", - "description": "Custom footer text" - }, - "title": { - "type": "string", - "description": "Site title" - }, - "message": { - "type": "string", - "description": "Welcome message" - } - }, - "required": ["name", "organization"] - }, "app": { - "type": "object", "description": "Application deployment configuration", "properties": { - "environment": { - "type": "string", - "enum": ["development", "staging", "production"], - "description": "Deployment environment" - }, - "replicas": { - "type": "integer", - "minimum": 1, - "maximum": 10, - "description": "Number of application replicas" - }, "config_data": { - "type": "string", - "description": "Application configuration data" + "description": "Application configuration data", + "type": "string" }, - "optional_setting": { - "type": "string", - "description": "Optional configuration setting" - }, - "required_setting": { - "type": "string", - "minLength": 1, - "description": "Required configuration setting" + "environment": { + "description": "Deployment environment", + "enum": [ + "development", + "staging", + "production" + ], + "type": "string" }, "features": { - "type": "array", + "description": "List of enabled features", "items": { "type": "string" }, - "description": "List of enabled features" + "type": "array" + }, + "image": { + "properties": { + "tag": { + "description": "Image tag version", + "pattern": "^v?[0-9]+\\.[0-9]+\\.[0-9]+$", + "type": "string" + } + }, + "type": "object" + }, + "name": { + "description": "Application name for Helm overrides", + "type": "string" + }, + "optional_setting": { + "description": "Optional configuration setting", + "type": "string" + }, + "port1": { + "type": "number" + }, + "port2": { + "type": "number" + }, + "port3": { + "type": "number" }, "ports": { - "type": "array", + "description": "Network ports", "items": { - "type": "integer", + "maximum": 65535, "minimum": 1, - "maximum": 65535 + "type": "number" }, - "description": "Network ports" + "type": "array" }, - "name": { - "type": "string", - "description": "Application name for Helm overrides" + "replicas": { + "description": "Number of application replicas", + "maximum": 10, + "minimum": 1, + "type": "number" }, - "image": { - "type": "object", - "properties": { - "tag": { - "type": "string", - "pattern": "^v?[0-9]+\\.[0-9]+\\.[0-9]+$", - "description": "Image tag version" - } - } + "required_setting": { + "description": "Required configuration setting", + "minLength": 1, + "type": "string" } }, - "required": ["environment", "replicas", "required_setting"] + "required": [ + "environment", + "replicas", + "required_setting" + ], + "type": "object" }, "database": { - "type": "object", "description": "Database configuration", "properties": { "host": { - "type": "string", - "description": "Database host" + "description": "Database host", + "type": "string" }, "host2": { - "type": "string", - "description": "Alternate database host" + "description": "Alternate database host", + "type": "string" + } + }, + "type": "object" + }, + "site": { + "description": "Site configuration and metadata", + "properties": { + "footer": { + "description": "Custom footer text", + "type": "string" + }, + "message": { + "description": "Welcome message", + "type": "string" + }, + "name": { + "description": "The name of the site you are deploying", + "minLength": 1, + "type": "string" + }, + "organization": { + "description": "The organization providing the site", + "type": "string" + }, + "styles": { + "description": "CSS styles for the site", + "type": "string" + }, + "title": { + "description": "Site title", + "type": "string" } - } + }, + "required": [ + "name", + "organization" + ], + "type": "object" } }, - "required": ["site", "app"] + "required": [ + "site", + "app" + ], + "type": "object" } \ No newline at end of file diff --git a/src/cmd/dev.go b/src/cmd/dev.go index 0e0beddde4..95e3e6d219 100644 --- a/src/cmd/dev.go +++ b/src/cmd/dev.go @@ -6,6 +6,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -25,6 +26,7 @@ import ( "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/config/lang" + "github.com/zarf-dev/zarf/src/internal/packager/helm" "github.com/zarf-dev/zarf/src/pkg/archive" "github.com/zarf-dev/zarf/src/pkg/lint" "github.com/zarf-dev/zarf/src/pkg/logger" @@ -73,11 +75,144 @@ func newDevCommand() *cobra.Command { cmd.AddCommand(newDevInspectCommand(v)) cmd.AddCommand(newDevFindImagesCommand(v)) cmd.AddCommand(newDevGenerateConfigCommand()) + cmd.AddCommand(newDevGenerateSchemaCommand(v)) cmd.AddCommand(newDevLintCommand(v)) return cmd } +type devGenerateSchemaOptions struct { + flavor string + setPkgTmpl map[string]string +} + +func newDevGenerateSchemaCommand(v *viper.Viper) *cobra.Command { + o := &devGenerateSchemaOptions{} + + cmd := &cobra.Command{ + Use: "generate-schema [ DIRECTORY ]", + Args: cobra.MaximumNArgs(1), + Short: "Generates a JSON schema for Zarf values based on the package definition and chart defaults", + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(cmd.Context(), args) + }, + } + + cmd.Flags().StringVarP(&o.flavor, "flavor", "f", "", lang.CmdPackageCreateFlagFlavor) + cmd.Flags().StringToStringVar(&o.setPkgTmpl, "set", v.GetStringMapString(VPkgCreateSet), lang.CmdPackageCreateFlagSetPkgTmpl) + + return cmd +} + +func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error { + l := logger.From(ctx) + + basePath, err := setBaseDirectory(args) + if err != nil { + return err + } + + cachePath, err := getCachePath(ctx) + if err != nil { + return err + } + + loadOpts := load.DefinitionOptions{ + Flavor: o.flavor, + SetVariables: o.setPkgTmpl, + IsInteractive: true, + SkipVersionCheck: true, + SkipSchemaValidation: true, + CachePath: cachePath, + RemoteOptions: defaultRemoteOptions(), + } + defined, err := load.PackageDefinition(ctx, basePath, loadOpts) + if err != nil { + return err + } + + // Step 1: Merge default values.files to create initial set of default Zarf values + valuesPaths := make([]string, len(defined.Pkg.Values.Files)) + for i, file := range defined.Pkg.Values.Files { + valuesPaths[i] = filepath.Join(basePath, file) + } + zarfValues, err := value.ParseFiles(ctx, valuesPaths, value.ParseFilesOptions{}) + if err != nil { + return fmt.Errorf("unable to parse package values files: %w", err) + } + + // Step 2: Discover source target mappings and load defaults from chart values where Zarf Value defaults aren't specified + tmpDir, err := utils.MakeTempDir(config.CommonOptions.TempDirectory) + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + for _, component := range defined.Pkg.Components { + for _, chart := range component.Charts { + chartPath := filepath.Join(tmpDir, "charts", chart.Name) + valuesFilePath := filepath.Join(tmpDir, "values") + + err := layout.PackageChart(ctx, chart, basePath, chartPath, valuesFilePath, cachePath, defaultRemoteOptions()) + if err != nil { + l.Warn("unable to package chart for schema generation", "chart", chart.Name, "error", err.Error()) + continue + } + + helmChart, valuesFilesValues, err := helm.LoadChartData(chart, chartPath, valuesFilePath, nil) + if err != nil { + l.Warn("unable to to load default values for chart", "chart", chart.Name, "error", err.Error()) + continue + } + + appliedValues := helpers.MergeMapRecursive(helmChart.Values, valuesFilesValues) + + // Map ChartValues' Source to Target and merge into zarfValues if not already present + for _, cv := range chart.Values { + if cv.SourcePath == "" || cv.TargetPath == "" { + l.Warn("skipping chart value mapping - sourcePath or targetPath is empty", "chart", chart.Name) + continue + } + val, err := value.Values(appliedValues).Extract(value.Path(cv.TargetPath)) + if err != nil { + l.Warn("skipping chart value mapping", "error", err.Error(), "chart", chart.Name, "targetPath", cv.TargetPath) + continue // skip if not found in defaults + } + + _, errExtract := zarfValues.Extract(value.Path(cv.SourcePath)) + if errExtract != nil { + zarfValues.Set(value.Path(cv.SourcePath), val) + } + } + } + } + + // Step 3: Generate JSON schema from the final map and save it + schema := value.GenerateJSONSchema(zarfValues) + + outputFileName := "values.schema.json" + existingSchema, err := value.LoadJSONSchema(outputFileName) + if err != nil { + return err + } + if existingSchema != nil { + schema = value.ReconcileJSONSchema(existingSchema, schema) + } + + b, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return fmt.Errorf("unable to marshal schema to JSON: %w", err) + } + + err = os.WriteFile(outputFileName, b, helpers.ReadAllWriteUser) + if err != nil { + return fmt.Errorf("unable to write schema file: %w", err) + } + + l.Info("Schema successfully generated", "filename", outputFileName) + return nil +} + func newDevInspectCommand(v *viper.Viper) *cobra.Command { cmd := &cobra.Command{ Use: "inspect", diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index 97a057c503..0d49a57dee 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -1051,14 +1051,10 @@ func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importe if !filepath.IsAbs(src) { src = filepath.Join(packagePath, relPath) } - b, err := os.ReadFile(src) + s, err := value.LoadJSONSchema(src) if err != nil { return nil, fmt.Errorf("reading %s schema: %w", label, err) } - var s map[string]any - if err := json.Unmarshal(b, &s); err != nil { - return nil, fmt.Errorf("parsing %s schema: %w", label, err) - } if err := value.CheckNoExternalRefs(s); err != nil { return nil, fmt.Errorf("%s schema %s: %w", label, relPath, err) } diff --git a/src/pkg/packager/load/load.go b/src/pkg/packager/load/load.go index ebb812f91a..05a959339d 100644 --- a/src/pkg/packager/load/load.go +++ b/src/pkg/packager/load/load.go @@ -33,6 +33,8 @@ type DefinitionOptions struct { // SkipRequiredValues ignores values schema validation errors when a "required" field is empty. Used when a package // value should be supplied at deploy-time and doesn't have a default set in the package values. SkipRequiredValues bool + // SkipSchemaValidation skips schema validation for the package values entirely. + SkipSchemaValidation bool // CachePath is used to cache layers from skeleton package pulls CachePath string // IsInteractive decides if Zarf can interactively prompt users through the CLI @@ -95,7 +97,7 @@ func PackageDefinition(ctx context.Context, packagePath string, opts DefinitionO return DefinedPackage{}, err } } - err = validate(ctx, pkg, pkgPath.ManifestFile, opts.SetVariables, opts.Flavor, opts.SkipRequiredValues) + err = validate(ctx, pkg, pkgPath.ManifestFile, opts.SetVariables, opts.Flavor, opts.SkipRequiredValues, opts.SkipSchemaValidation) if err != nil { return DefinedPackage{}, err } @@ -103,7 +105,7 @@ func PackageDefinition(ctx context.Context, packagePath string, opts DefinitionO return DefinedPackage{Pkg: pkg, ImportedSchemas: importedSchemas}, nil } -func validate(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, setVariables map[string]string, flavor string, skipRequiredValues bool) error { +func validate(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, setVariables map[string]string, flavor string, skipRequiredValues bool, skipSchemaValidation bool) error { l := logger.From(ctx) start := time.Now() l.Debug("start layout.Validate", @@ -131,8 +133,10 @@ func validate(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, } } - if err := validateValuesSchema(ctx, pkg, packagePath, validateValuesSchemaOptions{skipRequired: skipRequiredValues}); err != nil { - return err + if !skipSchemaValidation { + if err := validateValuesSchema(ctx, pkg, packagePath, validateValuesSchemaOptions{skipRequired: skipRequiredValues}); err != nil { + return err + } } l.Debug("done layout.Validate", diff --git a/src/pkg/value/generate.go b/src/pkg/value/generate.go new file mode 100644 index 0000000000..4e1628deed --- /dev/null +++ b/src/pkg/value/generate.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package value + +// GenerateJSONSchema infers a JSON schema from the structure and scalar types in values. +func GenerateJSONSchema(vals Values) map[string]any { + schema := map[string]any{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": make(map[string]any), + } + props := schema["properties"].(map[string]any) + + for k, v := range vals { + props[k] = inferSchemaType(v) + } + + return schema +} + +// ReconcileJSONSchema updates structural fields in an existing schema from inferred values. +// Non-structural fields (description/enum/required/etc.) are preserved by default. +func ReconcileJSONSchema(existing, inferred map[string]any) map[string]any { + typeVal, ok := inferred["type"] + if ok { + existing["type"] = typeVal + } + + typeStr, _ := typeVal.(string) + if typeStr == "object" { + reconcileSchemaProperties(existing, inferred) + } + + if typeStr == "array" { + reconcileSchemaItems(existing, inferred) + } + + if schemaURI, ok := inferred["$schema"]; ok { + existing["$schema"] = schemaURI + } + + return existing +} + +func reconcileSchemaProperties(existing, inferred map[string]any) { + inferredProps, ok := inferred["properties"].(map[string]any) + if !ok { + return + } + + existingProps, ok := existing["properties"].(map[string]any) + if !ok { + existingProps = make(map[string]any) + existing["properties"] = existingProps + } + + for key := range existingProps { + if _, found := inferredProps[key]; !found { + delete(existingProps, key) + } + } + + for key, inferredProp := range inferredProps { + inferredPropMap, ok := inferredProp.(map[string]any) + if !ok { + existingProps[key] = inferredProp + continue + } + + existingPropMap, ok := existingProps[key].(map[string]any) + if !ok { + existingProps[key] = inferredPropMap + continue + } + + existingProps[key] = ReconcileJSONSchema(existingPropMap, inferredPropMap) + } +} + +func reconcileSchemaItems(existing, inferred map[string]any) { + inferredItems, hasInferredItems := inferred["items"].(map[string]any) + if !hasInferredItems { + return + } + + existingItems, hasExistingItems := existing["items"].(map[string]any) + if !hasExistingItems { + existing["items"] = inferredItems + return + } + + existing["items"] = ReconcileJSONSchema(existingItems, inferredItems) +} + +func inferSchemaType(v any) any { + switch val := v.(type) { + case string: + return map[string]any{"type": "string"} + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return map[string]any{"type": "number"} + case bool: + return map[string]any{"type": "boolean"} + case map[string]any: + objProps := make(map[string]any) + for k, v := range val { + objProps[k] = inferSchemaType(v) + } + return map[string]any{ + "type": "object", + "properties": objProps, + } + case []any: + if len(val) > 0 { + return map[string]any{"type": "array", "items": inferSchemaType(val[0])} + } + return map[string]any{"type": "array"} + default: + return map[string]any{"type": "string"} + } +} diff --git a/src/pkg/value/generate_test.go b/src/pkg/value/generate_test.go new file mode 100644 index 0000000000..6c9693bcde --- /dev/null +++ b/src/pkg/value/generate_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package value + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateJSONSchema(t *testing.T) { + t.Run("infers nested types", func(t *testing.T) { + vals := Values{ + "name": "zarf", + "replicas": uint64(3), + "enabled": true, + "ports": []any{uint64(80)}, + "image": map[string]any{ + "tag": "v1.2.3", + }, + } + + schema := GenerateJSONSchema(vals) + + require.Equal(t, "http://json-schema.org/draft-07/schema#", schema["$schema"]) + require.Equal(t, "object", schema["type"]) + + props, ok := schema["properties"].(map[string]any) + require.True(t, ok) + + name, ok := props["name"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "string", name["type"]) + + replicas, ok := props["replicas"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "number", replicas["type"]) + + enabled, ok := props["enabled"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "boolean", enabled["type"]) + + ports, ok := props["ports"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "array", ports["type"]) + items, ok := ports["items"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "number", items["type"]) + + image, ok := props["image"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "object", image["type"]) + imageProps, ok := image["properties"].(map[string]any) + require.True(t, ok) + tag, ok := imageProps["tag"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "string", tag["type"]) + }) +} + +func TestReconcileJSONSchema(t *testing.T) { + t.Run("updates structure and preserves handcrafted metadata", func(t *testing.T) { + existing := map[string]any{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": map[string]any{ + "site": map[string]any{ + "type": "object", + "description": "Site configuration", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "Site name", + "minLength": float64(1), + }, + "legacy": map[string]any{ + "type": "string", + "description": "Old field", + }, + }, + "required": []any{"name"}, + }, + "features": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + "enum": []any{"alpha", "beta"}, + "minLength": float64(2), + }, + }, + "oldField": map[string]any{ + "type": "string", + }, + }, + } + + inferred := map[string]any{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": map[string]any{ + "site": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + "features": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "number", + }, + }, + "newField": map[string]any{ + "type": "string", + }, + }, + } + + result := ReconcileJSONSchema(existing, inferred) + + props := result["properties"].(map[string]any) + site := props["site"].(map[string]any) + assert.Equal(t, "Site configuration", site["description"]) // preserved + assert.Equal(t, []any{"name"}, site["required"]) // preserved + + siteProps := site["properties"].(map[string]any) + _, hasLegacy := siteProps["legacy"] + assert.False(t, hasLegacy) // removed (not inferred) + + name := siteProps["name"].(map[string]any) + assert.Equal(t, "Site name", name["description"]) // preserved + assert.Equal(t, float64(1), name["minLength"]) // preserved + assert.Equal(t, "string", name["type"]) // structural sync + + features := props["features"].(map[string]any) + items := features["items"].(map[string]any) + assert.Equal(t, "number", items["type"]) // updated from inferred + assert.Equal(t, []any{"alpha", "beta"}, items["enum"]) // preserved + assert.Equal(t, float64(2), items["minLength"]) // preserved + + _, hasNewField := props["newField"] + assert.True(t, hasNewField) // added (inferred) + + _, hasOldField := props["oldField"] + assert.False(t, hasOldField) // removed (not contained in inferred) + }) +} diff --git a/src/pkg/value/schema.go b/src/pkg/value/schema.go index 6ab7a69d04..61d374e49b 100644 --- a/src/pkg/value/schema.go +++ b/src/pkg/value/schema.go @@ -4,12 +4,33 @@ package value import ( + "encoding/json" + "errors" "fmt" "maps" + "os" "slices" "strings" ) +// LoadJSONSchema reads a JSON schema file if present. +func LoadJSONSchema(path string) (map[string]any, error) { + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("unable to read existing schema file: %w", err) + } + + var schema map[string]any + if err := json.Unmarshal(b, &schema); err != nil { + return nil, fmt.Errorf("unable to parse existing schema file: %w", err) + } + + return schema, nil +} + // CheckNoExternalRefs returns an error if the schema contains any external reference // pointers ($ref, $dynamicRef, $recursiveRef). Internal fragment references that start // with "#" — such as "#/definitions/Foo" or "#/$defs/Foo" — are allowed because the diff --git a/src/pkg/value/schema_test.go b/src/pkg/value/schema_test.go index 45bafea957..102ab38e98 100644 --- a/src/pkg/value/schema_test.go +++ b/src/pkg/value/schema_test.go @@ -4,12 +4,36 @@ package value import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestLoadJSONSchema(t *testing.T) { + t.Run("missing file returns nil schema and nil error", func(t *testing.T) { + schema, err := LoadJSONSchema(filepath.Join("testdata", "schema", "does-not-exist.schema.json")) + require.NoError(t, err) + require.Nil(t, schema) + }) + + t.Run("invalid json returns parse error", func(t *testing.T) { + schema, err := LoadJSONSchema(filepath.Join("testdata", "schema", "invalid-values.yaml")) + require.Nil(t, schema) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to parse existing schema file") + }) + + t.Run("valid json schema is loaded", func(t *testing.T) { + schema, err := LoadJSONSchema(filepath.Join("testdata", "schema", "simple.schema.json")) + require.NoError(t, err) + require.NotNil(t, schema) + require.Equal(t, "http://json-schema.org/draft-07/schema#", schema["$schema"]) + require.Equal(t, "object", schema["type"]) + }) +} + func TestCheckNoExternalRefs(t *testing.T) { t.Run("schema with no refs passes", func(t *testing.T) { schema := map[string]any{ From 6f96560ca8e3a50fc43c7bb899c822dea5d979a8 Mon Sep 17 00:00:00 2001 From: Wayne Starr Date: Fri, 5 Jun 2026 13:45:22 -0600 Subject: [PATCH 2/4] chore: add e2e test Signed-off-by: Wayne Starr --- src/test/e2e/13_zarf_package_generate_test.go | 39 --------- src/test/e2e/14_zarf_package_generate_test.go | 84 +++++++++++++++++++ ...compose_test.go => 15_oci_compose_test.go} | 0 .../14-generate-schema/chart/Chart.yaml | 6 ++ .../chart/templates/configmap.yaml | 8 ++ .../14-generate-schema/chart/values.yaml | 3 + .../14-generate-schema/values.schema.json | 24 ++++++ .../packages/14-generate-schema/values.yaml | 3 + .../packages/14-generate-schema/zarf.yaml | 24 ++++++ .../inception/zarf.yaml | 0 .../remote-resources/zarf.yaml | 0 .../zarf.yaml | 0 12 files changed, 152 insertions(+), 39 deletions(-) delete mode 100644 src/test/e2e/13_zarf_package_generate_test.go create mode 100644 src/test/e2e/14_zarf_package_generate_test.go rename src/test/e2e/{14_oci_compose_test.go => 15_oci_compose_test.go} (100%) create mode 100644 src/test/packages/14-generate-schema/chart/Chart.yaml create mode 100644 src/test/packages/14-generate-schema/chart/templates/configmap.yaml create mode 100644 src/test/packages/14-generate-schema/chart/values.yaml create mode 100644 src/test/packages/14-generate-schema/values.schema.json create mode 100644 src/test/packages/14-generate-schema/values.yaml create mode 100644 src/test/packages/14-generate-schema/zarf.yaml rename src/test/packages/{14-import-everything => 15-import-everything}/inception/zarf.yaml (100%) rename src/test/packages/{14-import-everything => 15-import-everything}/remote-resources/zarf.yaml (100%) rename src/test/packages/{14-import-everything => 15-import-everything}/zarf.yaml (100%) diff --git a/src/test/e2e/13_zarf_package_generate_test.go b/src/test/e2e/13_zarf_package_generate_test.go deleted file mode 100644 index 22c5048ced..0000000000 --- a/src/test/e2e/13_zarf_package_generate_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2021-Present The Zarf Authors - -// Package test provides e2e tests for Zarf. -package test - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "github.com/zarf-dev/zarf/src/api/v1alpha1" - "github.com/zarf-dev/zarf/src/pkg/packager/layout" - "github.com/zarf-dev/zarf/src/pkg/utils" -) - -func TestZarfDevGenerate(t *testing.T) { - t.Log("E2E: Zarf Dev Generate") - - t.Run("Test generate podinfo", func(t *testing.T) { - tmpDir := t.TempDir() - - url := "https://github.com/stefanprodan/podinfo.git" - version := "6.4.0" - gitPath := "charts/podinfo" - - stdOut, stdErr, err := e2e.Zarf(t, "dev", "generate", "podinfo", "--url", url, "--version", version, "--gitPath", gitPath, "--output-directory", tmpDir) - require.NoError(t, err, stdOut, stdErr) - - zarfPackage := v1alpha1.ZarfPackage{} - packageLocation := filepath.Join(tmpDir, layout.ZarfYAML) - err = utils.ReadYaml(packageLocation, &zarfPackage) - require.NoError(t, err) - require.Equal(t, zarfPackage.Components[0].Charts[0].URL, url) - require.Equal(t, zarfPackage.Components[0].Charts[0].Version, version) - require.Equal(t, zarfPackage.Components[0].Charts[0].GitPath, gitPath) - require.NotEmpty(t, zarfPackage.Components[0].Images) - }) -} diff --git a/src/test/e2e/14_zarf_package_generate_test.go b/src/test/e2e/14_zarf_package_generate_test.go new file mode 100644 index 0000000000..9c8bc27fc2 --- /dev/null +++ b/src/test/e2e/14_zarf_package_generate_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package test provides e2e tests for Zarf. +package test + +import ( + "path/filepath" + "testing" + + "github.com/defenseunicorns/pkg/helpers/v2" + "github.com/stretchr/testify/require" + "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/pkg/packager/layout" + "github.com/zarf-dev/zarf/src/pkg/utils" + "github.com/zarf-dev/zarf/src/pkg/value" +) + +func TestZarfDevGenerate(t *testing.T) { + t.Log("E2E: Zarf Dev Generate") + + t.Run("Test generate podinfo", func(t *testing.T) { + tmpDir := t.TempDir() + + url := "https://github.com/stefanprodan/podinfo.git" + version := "6.4.0" + gitPath := "charts/podinfo" + + stdOut, stdErr, err := e2e.Zarf(t, "dev", "generate", "podinfo", "--url", url, "--version", version, "--gitPath", gitPath, "--output-directory", tmpDir) + require.NoError(t, err, stdOut, stdErr) + + zarfPackage := v1alpha1.ZarfPackage{} + packageLocation := filepath.Join(tmpDir, layout.ZarfYAML) + err = utils.ReadYaml(packageLocation, &zarfPackage) + require.NoError(t, err) + require.Equal(t, zarfPackage.Components[0].Charts[0].URL, url) + require.Equal(t, zarfPackage.Components[0].Charts[0].Version, version) + require.Equal(t, zarfPackage.Components[0].Charts[0].GitPath, gitPath) + require.NotEmpty(t, zarfPackage.Components[0].Images) + }) + + t.Run("Test generate-schema merges inferred data into existing schema", func(t *testing.T) { + packagePath := t.TempDir() + err := helpers.CreatePathAndCopy("src/test/packages/14-generate-schema", packagePath) + require.NoError(t, err) + + stdOut, stdErr, err := e2e.ZarfInDir(t, packagePath, "dev", "generate-schema", ".", "--features=values=true") + require.NoError(t, err, stdOut, stdErr) + + schemaPath := filepath.Join(packagePath, "values.schema.json") + schema, err := value.LoadJSONSchema(schemaPath) + require.NoError(t, err) + require.NotNil(t, schema) + + props, ok := schema["properties"].(map[string]any) + require.True(t, ok) + + app, ok := props["app"].(map[string]any) + require.True(t, ok) + appProps, ok := app["properties"].(map[string]any) + require.True(t, ok) + + name, ok := appProps["name"].(map[string]any) + require.True(t, ok) + require.Equal(t, "string", name["type"]) + require.Equal(t, "Application name", name["description"]) + + replicas, ok := appProps["replicas"].(map[string]any) + require.True(t, ok) + require.Equal(t, "number", replicas["type"]) + require.Equal(t, "Replica count", replicas["description"]) + + network, ok := props["network"].(map[string]any) + require.True(t, ok) + networkProps, ok := network["properties"].(map[string]any) + require.True(t, ok) + port, ok := networkProps["port"].(map[string]any) + require.True(t, ok) + require.Equal(t, "number", port["type"]) + + _, hasOldField := props["oldField"] + require.False(t, hasOldField) + }) +} diff --git a/src/test/e2e/14_oci_compose_test.go b/src/test/e2e/15_oci_compose_test.go similarity index 100% rename from src/test/e2e/14_oci_compose_test.go rename to src/test/e2e/15_oci_compose_test.go diff --git a/src/test/packages/14-generate-schema/chart/Chart.yaml b/src/test/packages/14-generate-schema/chart/Chart.yaml new file mode 100644 index 0000000000..ba1a83021f --- /dev/null +++ b/src/test/packages/14-generate-schema/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: schema-chart +description: Chart fixture for zarf dev generate-schema tests +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/src/test/packages/14-generate-schema/chart/templates/configmap.yaml b/src/test/packages/14-generate-schema/chart/templates/configmap.yaml new file mode 100644 index 0000000000..4c4d62fbba --- /dev/null +++ b/src/test/packages/14-generate-schema/chart/templates/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: schema-chart-values +data: + name: {{ .Values.name | default "unknown" | quote }} + replicas: {{ .Values.replicaCount | quote }} + port: {{ .Values.service.port | quote }} diff --git a/src/test/packages/14-generate-schema/chart/values.yaml b/src/test/packages/14-generate-schema/chart/values.yaml new file mode 100644 index 0000000000..ab4f448c78 --- /dev/null +++ b/src/test/packages/14-generate-schema/chart/values.yaml @@ -0,0 +1,3 @@ +replicaCount: 1 +service: + port: 8080 diff --git a/src/test/packages/14-generate-schema/values.schema.json b/src/test/packages/14-generate-schema/values.schema.json new file mode 100644 index 0000000000..2443085082 --- /dev/null +++ b/src/test/packages/14-generate-schema/values.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "app": { + "type": "object", + "description": "Application configuration", + "properties": { + "name": { + "type": "number", + "description": "Application name" + }, + "replicas": { + "type": "string", + "description": "Replica count" + } + } + }, + "oldField": { + "type": "string", + "description": "Should be removed" + } + } +} diff --git a/src/test/packages/14-generate-schema/values.yaml b/src/test/packages/14-generate-schema/values.yaml new file mode 100644 index 0000000000..259bb3f7eb --- /dev/null +++ b/src/test/packages/14-generate-schema/values.yaml @@ -0,0 +1,3 @@ +app: + name: schema-app + replicas: 2 diff --git a/src/test/packages/14-generate-schema/zarf.yaml b/src/test/packages/14-generate-schema/zarf.yaml new file mode 100644 index 0000000000..97ac4c147c --- /dev/null +++ b/src/test/packages/14-generate-schema/zarf.yaml @@ -0,0 +1,24 @@ +kind: ZarfPackageConfig +metadata: + name: generate-schema-test + description: Package fixture for zarf dev generate-schema e2e tests + +values: + files: + - values.yaml + schema: values.schema.json + +components: + - name: schema-chart + required: true + charts: + - name: schema-chart + version: 0.1.0 + releaseName: schema-chart + namespace: schema-test + localPath: chart + values: + - sourcePath: ".app.replicas" + targetPath: ".replicaCount" + - sourcePath: ".network.port" + targetPath: ".service.port" diff --git a/src/test/packages/14-import-everything/inception/zarf.yaml b/src/test/packages/15-import-everything/inception/zarf.yaml similarity index 100% rename from src/test/packages/14-import-everything/inception/zarf.yaml rename to src/test/packages/15-import-everything/inception/zarf.yaml diff --git a/src/test/packages/14-import-everything/remote-resources/zarf.yaml b/src/test/packages/15-import-everything/remote-resources/zarf.yaml similarity index 100% rename from src/test/packages/14-import-everything/remote-resources/zarf.yaml rename to src/test/packages/15-import-everything/remote-resources/zarf.yaml diff --git a/src/test/packages/14-import-everything/zarf.yaml b/src/test/packages/15-import-everything/zarf.yaml similarity index 100% rename from src/test/packages/14-import-everything/zarf.yaml rename to src/test/packages/15-import-everything/zarf.yaml From ed9f30fc76b5ec2b96329ff8272ce044b2805a72 Mon Sep 17 00:00:00 2001 From: Wayne Starr Date: Fri, 5 Jun 2026 13:54:10 -0600 Subject: [PATCH 3/4] chore: docs and lint Signed-off-by: Wayne Starr --- site/src/content/docs/commands/zarf_dev.md | 1 + .../docs/commands/zarf_dev_generate-schema.md | 42 +++++++++++++++++++ src/cmd/dev.go | 10 ++++- src/pkg/value/generate.go | 13 +++--- src/pkg/value/generate_test.go | 26 +++++++----- 5 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 site/src/content/docs/commands/zarf_dev_generate-schema.md diff --git a/site/src/content/docs/commands/zarf_dev.md b/site/src/content/docs/commands/zarf_dev.md index 9602574a31..ed815c6edd 100644 --- a/site/src/content/docs/commands/zarf_dev.md +++ b/site/src/content/docs/commands/zarf_dev.md @@ -37,6 +37,7 @@ Commands useful for developing packages * [zarf dev find-images](/commands/zarf_dev_find-images/) - Evaluates components in a Zarf file to identify images specified in their helm charts and manifests. * [zarf dev generate](/commands/zarf_dev_generate/) - Creates a zarf.yaml automatically from a given remote (git) Helm chart * [zarf dev generate-config](/commands/zarf_dev_generate-config/) - Generates a config file for Zarf +* [zarf dev generate-schema](/commands/zarf_dev_generate-schema/) - Generates a JSON schema for Zarf values based on the package definition and chart defaults * [zarf dev inspect](/commands/zarf_dev_inspect/) - Commands to gather information about a Zarf package using its package definition * [zarf dev lint](/commands/zarf_dev_lint/) - Lints the given package for valid schema and recommended practices * [zarf dev patch-git](/commands/zarf_dev_patch-git/) - Converts all .git URLs to the specified Zarf HOST and with the Zarf URL pattern in a given FILE. NOTE: diff --git a/site/src/content/docs/commands/zarf_dev_generate-schema.md b/site/src/content/docs/commands/zarf_dev_generate-schema.md new file mode 100644 index 0000000000..59d0dd1f33 --- /dev/null +++ b/site/src/content/docs/commands/zarf_dev_generate-schema.md @@ -0,0 +1,42 @@ +--- +title: zarf dev generate-schema +description: Zarf CLI command reference for zarf dev generate-schema. +tableOfContents: false +--- + + + +## zarf dev generate-schema + +Generates a JSON schema for Zarf values based on the package definition and chart defaults + +``` +zarf dev generate-schema [ DIRECTORY ] [flags] +``` + +### Options + +``` + -f, --flavor string The flavor of components to include in the resulting package (i.e. have a matching or empty "only.flavor" key) + -h, --help help for generate-schema + --set stringToString Specify package templates to set on the command line (KEY=value) (default []) +``` + +### Options inherited from parent commands + +``` + -a, --architecture string Architecture for OCI images and Zarf packages + --features stringToString Provide a comma-separated list of feature names to bools to enable or disable. Ex. --features "foo=true,bar=false,baz=true" (default []) + --insecure-skip-tls-verify Skip checking server's certificate for validity. This flag should only be used if you have a specific reason and accept the reduced security posture. + --log-format string Select a logging format. Defaults to 'console'. Valid options are: 'console', 'json', 'dev'. (default "console") + -l, --log-level string Log level when running Zarf. Valid options are: warn, info, debug, trace (default "info") + --no-color Disable terminal color codes in logging and stdout prints. + --plain-http Force the connections over HTTP instead of HTTPS. This flag should only be used if you have a specific reason and accept the reduced security posture. + --tmpdir string Specify the temporary directory to use for intermediate files + --zarf-cache string Specify the location of the Zarf cache directory (default "~/.zarf-cache") +``` + +### SEE ALSO + +* [zarf dev](/commands/zarf_dev/) - Commands useful for developing packages + diff --git a/src/cmd/dev.go b/src/cmd/dev.go index 95e3e6d219..c9b5ef0fa8 100644 --- a/src/cmd/dev.go +++ b/src/cmd/dev.go @@ -146,7 +146,11 @@ func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error if err != nil { return err } - defer os.RemoveAll(tmpDir) + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + l.Warn("unable to clean up temporary directory", "path", tmpDir, "error", err.Error()) + } + }() for _, component := range defined.Pkg.Components { for _, chart := range component.Charts { @@ -181,7 +185,9 @@ func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error _, errExtract := zarfValues.Extract(value.Path(cv.SourcePath)) if errExtract != nil { - zarfValues.Set(value.Path(cv.SourcePath), val) + if err := zarfValues.Set(value.Path(cv.SourcePath), val); err != nil { + l.Warn("skipping chart value mapping", "error", err.Error(), "chart", chart.Name, "sourcePath", cv.SourcePath) + } } } } diff --git a/src/pkg/value/generate.go b/src/pkg/value/generate.go index 4e1628deed..0f3fb58365 100644 --- a/src/pkg/value/generate.go +++ b/src/pkg/value/generate.go @@ -5,12 +5,12 @@ package value // GenerateJSONSchema infers a JSON schema from the structure and scalar types in values. func GenerateJSONSchema(vals Values) map[string]any { + props := make(map[string]any) schema := map[string]any{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "properties": make(map[string]any), + "properties": props, } - props := schema["properties"].(map[string]any) for k, v := range vals { props[k] = inferSchemaType(v) @@ -22,12 +22,15 @@ func GenerateJSONSchema(vals Values) map[string]any { // ReconcileJSONSchema updates structural fields in an existing schema from inferred values. // Non-structural fields (description/enum/required/etc.) are preserved by default. func ReconcileJSONSchema(existing, inferred map[string]any) map[string]any { - typeVal, ok := inferred["type"] - if ok { + typeVal, hasType := inferred["type"] + if hasType { existing["type"] = typeVal } - typeStr, _ := typeVal.(string) + typeStr, ok := typeVal.(string) + if !ok { + typeStr = "" + } if typeStr == "object" { reconcileSchemaProperties(existing, inferred) } diff --git a/src/pkg/value/generate_test.go b/src/pkg/value/generate_test.go index 6c9693bcde..9b6ef3ff2e 100644 --- a/src/pkg/value/generate_test.go +++ b/src/pkg/value/generate_test.go @@ -120,25 +120,31 @@ func TestReconcileJSONSchema(t *testing.T) { result := ReconcileJSONSchema(existing, inferred) - props := result["properties"].(map[string]any) - site := props["site"].(map[string]any) + props, ok := result["properties"].(map[string]any) + require.True(t, ok) + site, ok := props["site"].(map[string]any) + require.True(t, ok) assert.Equal(t, "Site configuration", site["description"]) // preserved assert.Equal(t, []any{"name"}, site["required"]) // preserved - siteProps := site["properties"].(map[string]any) + siteProps, ok := site["properties"].(map[string]any) + require.True(t, ok) _, hasLegacy := siteProps["legacy"] assert.False(t, hasLegacy) // removed (not inferred) - name := siteProps["name"].(map[string]any) - assert.Equal(t, "Site name", name["description"]) // preserved - assert.Equal(t, float64(1), name["minLength"]) // preserved - assert.Equal(t, "string", name["type"]) // structural sync + name, ok := siteProps["name"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "Site name", name["description"]) // preserved + assert.InDelta(t, float64(1), name["minLength"], 0) // preserved + assert.Equal(t, "string", name["type"]) // structural sync - features := props["features"].(map[string]any) - items := features["items"].(map[string]any) + features, ok := props["features"].(map[string]any) + require.True(t, ok) + items, ok := features["items"].(map[string]any) + require.True(t, ok) assert.Equal(t, "number", items["type"]) // updated from inferred assert.Equal(t, []any{"alpha", "beta"}, items["enum"]) // preserved - assert.Equal(t, float64(2), items["minLength"]) // preserved + assert.InDelta(t, float64(2), items["minLength"], 0) // preserved _, hasNewField := props["newField"] assert.True(t, hasNewField) // added (inferred) From ba8804b8c3e1b602e42ab6113adeb19142a399df Mon Sep 17 00:00:00 2001 From: Wayne Starr Date: Fri, 5 Jun 2026 14:25:14 -0600 Subject: [PATCH 4/4] chore: fix e2e test Signed-off-by: Wayne Starr --- src/test/e2e/15_oci_compose_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/e2e/15_oci_compose_test.go b/src/test/e2e/15_oci_compose_test.go index 424a3dbcfe..31643e1c24 100644 --- a/src/test/e2e/15_oci_compose_test.go +++ b/src/test/e2e/15_oci_compose_test.go @@ -39,17 +39,17 @@ type PublishCopySkeletonSuite struct { } var ( - importEverything = filepath.Join("src", "test", "packages", "14-import-everything") + importEverything = filepath.Join("src", "test", "packages", "15-import-everything") importEverythingPath string - importception = filepath.Join("src", "test", "packages", "14-import-everything", "inception") + importception = filepath.Join("src", "test", "packages", "15-import-everything", "inception") importceptionPath string - importRemoteResources = filepath.Join("src", "test", "packages", "14-import-everything", "remote-resources") + importRemoteResources = filepath.Join("src", "test", "packages", "15-import-everything", "remote-resources") ) func (suite *PublishCopySkeletonSuite) SetupSuite() { suite.Assertions = require.New(suite.T()) - // This port must match the registry URL in 14-import-everything/zarf.yaml + // This port must match the registry URL in 15-import-everything/zarf.yaml suite.Reference.Registry = testutil.SetupInMemoryRegistry(testutil.TestContext(suite.T()), suite.T(), 31888) suite.PackagesDir = suite.T().TempDir() // Setup the package paths after e2e has been initialized @@ -58,7 +58,7 @@ func (suite *PublishCopySkeletonSuite) SetupSuite() { } func (suite *PublishCopySkeletonSuite) TearDownSuite() { - err := os.RemoveAll(filepath.Join("src", "test", "packages", "14-import-everything", "charts", "local")) + err := os.RemoveAll(filepath.Join("src", "test", "packages", "15-import-everything", "charts", "local")) suite.NoError(err) err = os.RemoveAll(importEverythingPath) suite.NoError(err)