Skip to content

Commit 9c79597

Browse files
authored
feat(values)!: support schema merge on imports (#4951)
Signed-off-by: Brandt Keller <brandt.keller@defenseunicorns.com>
1 parent b4d7f59 commit 9c79597

43 files changed

Lines changed: 1216 additions & 102 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/cmd/dev.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ func (o *devInspectDefinitionOptions) run(cmd *cobra.Command, args []string) err
133133
if err != nil {
134134
return err
135135
}
136-
pkg, err := load.PackageDefinition(ctx, basePath, loadOpts)
136+
defined, err := load.PackageDefinition(ctx, basePath, loadOpts)
137137
if err != nil {
138138
return err
139139
}
140-
pkg.Build = v1alpha1.ZarfBuildData{}
141-
err = utils.ColorPrintYAML(pkg, nil, false)
140+
defined.Pkg.Build = v1alpha1.ZarfBuildData{}
141+
err = utils.ColorPrintYAML(defined.Pkg, nil, false)
142142
if err != nil {
143143
return err
144144
}

src/pkg/packager/create.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func Create(ctx context.Context, packagePath string, output string, opts CreateO
6363
SkipVersionCheck: opts.SkipVersionCheck,
6464
RemoteOptions: opts.RemoteOptions,
6565
}
66-
pkg, err := load.PackageDefinition(ctx, packagePath, loadOpts)
66+
defined, err := load.PackageDefinition(ctx, packagePath, loadOpts)
6767
if err != nil {
6868
return "", err
6969
}
@@ -76,7 +76,7 @@ func Create(ctx context.Context, packagePath string, output string, opts CreateO
7676
var differentialPkg v1alpha1.ZarfPackage
7777
if opts.DifferentialPackagePath != "" {
7878
pkgLayout, err := LoadPackage(ctx, opts.DifferentialPackagePath, LoadOptions{
79-
Architecture: pkg.Metadata.Architecture,
79+
Architecture: defined.Pkg.Metadata.Architecture,
8080
RemoteOptions: opts.RemoteOptions,
8181
LayerTypes: []zoci.LayerType{zoci.MetadataLayers},
8282
OCIConcurrency: opts.OCIConcurrency,
@@ -103,7 +103,7 @@ func Create(ctx context.Context, packagePath string, output string, opts CreateO
103103
WithBuildMachineInfo: opts.WithBuildMachineInfo,
104104
RemoteOptions: opts.RemoteOptions,
105105
}
106-
pkgLayout, err := layout.AssemblePackage(ctx, pkg, pkgPath.BaseDir, assembleOpt)
106+
pkgLayout, err := layout.AssemblePackage(ctx, defined.Pkg, pkgPath.BaseDir, defined.ImportedSchemas, assembleOpt)
107107
if err != nil {
108108
return "", err
109109
}

src/pkg/packager/deploy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func Deploy(ctx context.Context, pkgLayout *layout.PackageLayout, opts DeployOpt
169169
vals.DeepMerge(opts.Values)
170170

171171
// Validate merged values against schema if provided
172-
if pkgLayout.Pkg.Values.Schema != "" {
172+
if pkgLayout.HasValuesSchema() {
173173
schemaPath := filepath.Join(pkgLayout.DirPath(), layout.ValuesSchema)
174174
if err := vals.Validate(ctx, schemaPath, value.ValidateOptions{}); err != nil {
175175
return DeployResult{}, fmt.Errorf("values validation failed: %w", err)

src/pkg/packager/dev.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func DevDeploy(ctx context.Context, packagePath string, opts DevDeployOptions) (
7878
SkipVersionCheck: opts.SkipVersionCheck,
7979
RemoteOptions: opts.RemoteOptions,
8080
}
81-
pkg, err := load.PackageDefinition(ctx, packagePath, loadOpts)
81+
defined, err := load.PackageDefinition(ctx, packagePath, loadOpts)
8282
if err != nil {
8383
return err
8484
}
@@ -87,17 +87,17 @@ func DevDeploy(ctx context.Context, packagePath string, opts DevDeployOptions) (
8787
filters.ByLocalOS(runtime.GOOS),
8888
filters.ForDeploy(opts.OptionalComponents, false),
8989
)
90-
pkg.Components, err = filter.Apply(pkg)
90+
defined.Pkg.Components, err = filter.Apply(defined.Pkg)
9191
if err != nil {
9292
return err
9393
}
9494

9595
// If not building for airgap, strip out all images and repos
9696
if !opts.AirgapMode {
97-
for idx := range pkg.Components {
98-
pkg.Components[idx].Images = []string{}
99-
pkg.Components[idx].ImageArchives = []v1alpha1.ImageArchive{}
100-
pkg.Components[idx].Repos = []string{}
97+
for idx := range defined.Pkg.Components {
98+
defined.Pkg.Components[idx].Images = []string{}
99+
defined.Pkg.Components[idx].ImageArchives = []v1alpha1.ImageArchive{}
100+
defined.Pkg.Components[idx].Repos = []string{}
101101
}
102102
}
103103

@@ -108,7 +108,7 @@ func DevDeploy(ctx context.Context, packagePath string, opts DevDeployOptions) (
108108
OCIConcurrency: opts.OCIConcurrency,
109109
CachePath: opts.CachePath,
110110
}
111-
pkgLayout, err := layout.AssemblePackage(ctx, pkg, packagePath, createOpts)
111+
pkgLayout, err := layout.AssemblePackage(ctx, defined.Pkg, packagePath, defined.ImportedSchemas, createOpts)
112112
if err != nil {
113113
return err
114114
}

src/pkg/packager/find_images.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,16 @@ func FindDefinitionImages(ctx context.Context, packagePath string, opts FindImag
108108
SkipVersionCheck: true,
109109
RemoteOptions: opts.RemoteOptions,
110110
}
111-
pkg, err := load.PackageDefinition(ctx, packagePath, loadOpts)
111+
defined, err := load.PackageDefinition(ctx, packagePath, loadOpts)
112112
if err != nil {
113113
return nil, err
114114
}
115-
imageScans, err := findImages(ctx, pkg, packagePath, opts)
115+
imageScans, err := findImages(ctx, defined.Pkg, packagePath, opts)
116116
if err != nil {
117117
return nil, err
118118
}
119119

120-
return filterImagesFoundInArchives(ctx, pkg, packagePath, imageScans)
120+
return filterImagesFoundInArchives(ctx, defined.Pkg, packagePath, imageScans)
121121
}
122122

123123
// FindImages iterates over the manifests and charts within each component to find any container images
@@ -136,12 +136,12 @@ func FindImages(ctx context.Context, packagePath string, opts FindImagesOptions)
136136
SkipVersionCheck: true,
137137
RemoteOptions: opts.RemoteOptions,
138138
}
139-
pkg, err := load.PackageDefinition(ctx, packagePath, loadOpts)
139+
defined, err := load.PackageDefinition(ctx, packagePath, loadOpts)
140140
if err != nil {
141141
return nil, err
142142
}
143143

144-
return findImages(ctx, pkg, packagePath, opts)
144+
return findImages(ctx, defined.Pkg, packagePath, opts)
145145
}
146146

147147
// filterImagesFoundInArchives merges scan results with each component's imageArchives.

src/pkg/packager/inspect.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func InspectPackageResources(ctx context.Context, pkgLayout *layout.PackageLayou
7979
}
8080
vals.DeepMerge(opts.Values)
8181

82-
if pkgLayout.Pkg.Values.Schema != "" {
82+
if pkgLayout.HasValuesSchema() {
8383
schemaPath := filepath.Join(pkgLayout.DirPath(), layout.ValuesSchema)
8484
if err := vals.Validate(ctx, schemaPath, value.ValidateOptions{SkipRequired: true}); err != nil {
8585
return nil, fmt.Errorf("inspect values validation failed: %w", err)
@@ -246,10 +246,11 @@ func InspectDefinitionResources(ctx context.Context, packagePath string, opts In
246246
SkipVersionCheck: true,
247247
RemoteOptions: opts.RemoteOptions,
248248
}
249-
pkg, err := load.PackageDefinition(ctx, packagePath, loadOpts)
249+
defined, err := load.PackageDefinition(ctx, packagePath, loadOpts)
250250
if err != nil {
251251
return nil, err
252252
}
253+
pkg := defined.Pkg
253254
variableConfig, err := getPopulatedVariableConfig(ctx, pkg, opts.DeploySetVariables, opts.IsInteractive)
254255
if err != nil {
255256
return nil, err

src/pkg/packager/layout/assemble.go

Lines changed: 121 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ type AssembleOptions struct {
6262
}
6363

6464
// AssemblePackage takes a package definition and returns a package layout with all the resources collected
65-
func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, opts AssembleOptions) (*PackageLayout, error) {
65+
func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, importedSchemas []string, opts AssembleOptions) (*PackageLayout, error) {
6666
l := logger.From(ctx)
6767
l.Info("assembling package", "path", packagePath)
6868

@@ -180,11 +180,8 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath
180180
return nil, err
181181
}
182182

183-
// Copy schema file if specified
184-
if pkg.Values.Schema != "" {
185-
if err = copyValuesSchema(ctx, pkg.Values.Schema, packagePath, buildPath); err != nil {
186-
return nil, err
187-
}
183+
if err = mergeAndWriteValuesSchema(ctx, pkg.Values.Schema, importedSchemas, packagePath, buildPath); err != nil {
184+
return nil, err
188185
}
189186

190187
if err = createDocumentationTar(pkg, packagePath, buildPath); err != nil {
@@ -244,11 +241,11 @@ type AssembleSkeletonOptions struct {
244241
}
245242

246243
// AssembleSkeleton creates a skeleton package and returns the path to the created package.
247-
func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, opts AssembleSkeletonOptions) (*PackageLayout, error) {
244+
func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, importedSchemas []string, opts AssembleSkeletonOptions) (*PackageLayout, error) {
248245
pkg.Metadata.Architecture = v1alpha1.SkeletonArch
249246

250-
// Creating skeletons packages with the values feature is not yet supported
251-
if len(pkg.Values.Files) > 0 || pkg.Values.Schema != "" {
247+
// Creating skeleton packages with the values feature is not yet supported
248+
if len(pkg.Values.Files) > 0 || pkg.Values.Schema != "" || len(importedSchemas) > 0 {
252249
return nil, errors.New("creating skeleton packages with the values feature is not yet supported")
253250
}
254251

@@ -1031,36 +1028,131 @@ func mergeAndWriteValuesFile(ctx context.Context, files []string, packagePath, b
10311028
return nil
10321029
}
10331030

1034-
// copyValuesSchema validates and copies a values schema file to the build directory.
1035-
// It validates the schema is valid JSON Schema, checks for path traversal, and copies
1036-
// the file to the package root.
1037-
func copyValuesSchema(ctx context.Context, schema, packagePath, buildPath string) error {
1031+
// mergeAndWriteValuesSchema merges imported child schemas with the parent schema (parent wins)
1032+
// and writes the result to buildPath/values.schema.json. If only a parent schema exists with
1033+
// no imports, it is validated and copied as-is. If only child schemas exist, they are merged
1034+
// and written. If neither exists, the function is a no-op.
1035+
//
1036+
// Schemas containing "$ref" pointers are rejected in all cases because references may point
1037+
// to files unavailable after assembly.
1038+
func mergeAndWriteValuesSchema(ctx context.Context, parentSchema string, importedSchemas []string, packagePath, buildPath string) error {
10381039
l := logger.From(ctx)
1039-
l.Debug("copying values schema file to package", "schema", schema)
10401040

1041-
// Resolve the schema source path from package root
1042-
schemaSrc := schema
1043-
if !filepath.IsAbs(schemaSrc) {
1044-
schemaSrc = filepath.Join(packagePath, schema)
1041+
if parentSchema == "" && len(importedSchemas) == 0 {
1042+
return nil
1043+
}
1044+
1045+
// loadSchema reads a schema file, rejects any external "$ref" pointers before handing
1046+
// the document to gojsonschema, then validates the schema structure. CheckNoExternalRefs
1047+
// runs first so that gojsonschema never attempts to resolve external URIs.
1048+
loadSchema := func(relPath, label string) (map[string]any, error) {
1049+
src := relPath
1050+
if !filepath.IsAbs(src) {
1051+
src = filepath.Join(packagePath, relPath)
1052+
}
1053+
b, err := os.ReadFile(src)
1054+
if err != nil {
1055+
return nil, fmt.Errorf("reading %s schema: %w", label, err)
1056+
}
1057+
var s map[string]any
1058+
if err := json.Unmarshal(b, &s); err != nil {
1059+
return nil, fmt.Errorf("parsing %s schema: %w", label, err)
1060+
}
1061+
if err := value.CheckNoExternalRefs(s); err != nil {
1062+
return nil, fmt.Errorf("%s schema %s: %w", label, relPath, err)
1063+
}
1064+
if err := value.ValidateSchemaDocument(s); err != nil {
1065+
return nil, fmt.Errorf("%s schema validation failed: %w", label, err)
1066+
}
1067+
return s, nil
1068+
}
1069+
1070+
// No child schemas — check for $ref, validate, then copy the parent schema file verbatim.
1071+
if len(importedSchemas) == 0 {
1072+
src := parentSchema
1073+
if !filepath.IsAbs(src) {
1074+
src = filepath.Join(packagePath, parentSchema)
1075+
}
1076+
if _, err := loadSchema(parentSchema, "parent"); err != nil {
1077+
return err
1078+
}
1079+
dst := filepath.Join(buildPath, ValuesSchema)
1080+
l.Debug("copying values schema file", "src", src, "dst", dst)
1081+
if err := helpers.CreatePathAndCopy(src, dst); err != nil {
1082+
return fmt.Errorf("failed to copy values schema file %s: %w", parentSchema, err)
1083+
}
1084+
return os.Chmod(dst, helpers.ReadWriteUser)
1085+
}
1086+
1087+
l.Debug("merging values schemas", "parent", parentSchema, "imported", len(importedSchemas))
1088+
1089+
// Merge child schemas left-to-right; among children the earlier one wins.
1090+
var merged map[string]any
1091+
for _, schemaRelPath := range importedSchemas {
1092+
child, err := loadSchema(schemaRelPath, "imported")
1093+
if err != nil {
1094+
return err
1095+
}
1096+
if schemaVersion(child) == "" {
1097+
return fmt.Errorf("imported schema %s: missing \"$schema\" version declaration; all schemas being merged must specify a version", schemaRelPath)
1098+
}
1099+
if merged == nil {
1100+
merged = child
1101+
} else {
1102+
if err := checkCompatibleVersion(merged, child, schemaRelPath); err != nil {
1103+
return err
1104+
}
1105+
merged = value.MergeSchemas(merged, child)
1106+
}
10451107
}
10461108

1047-
// Validate the schema is valid JSON Schema
1048-
if err := value.ValidateSchemaFile(schemaSrc); err != nil {
1049-
return fmt.Errorf("values schema validation failed: %w", err)
1109+
// Load the parent schema and merge it on top — parent wins over all children.
1110+
if parentSchema != "" {
1111+
parent, err := loadSchema(parentSchema, "parent")
1112+
if err != nil {
1113+
return err
1114+
}
1115+
if schemaVersion(parent) == "" {
1116+
return fmt.Errorf("parent schema %s: missing \"$schema\" version declaration; all schemas being merged must specify a version", parentSchema)
1117+
}
1118+
if err := checkCompatibleVersion(parent, merged, "imported schemas"); err != nil {
1119+
return err
1120+
}
1121+
merged = value.MergeSchemas(parent, merged)
10501122
}
10511123

1052-
// Copy schema file to package root
1053-
schemaDst := filepath.Join(buildPath, ValuesSchema)
1054-
l.Debug("copying values schema file", "src", schemaSrc, "dst", schemaDst)
1055-
if err := helpers.CreatePathAndCopy(schemaSrc, schemaDst); err != nil {
1056-
return fmt.Errorf("failed to copy values schema file %s: %w", schemaSrc, err)
1124+
if err := value.ValidateSchemaDocument(merged); err != nil {
1125+
return fmt.Errorf("merged values schema is invalid: %w", err)
10571126
}
10581127

1059-
// Set appropriate file permissions
1060-
if err := os.Chmod(schemaDst, helpers.ReadWriteUser); err != nil {
1061-
return fmt.Errorf("failed to set permissions on values schema file %s: %w", schemaDst, err)
1128+
dst := filepath.Join(buildPath, ValuesSchema)
1129+
l.Debug("writing merged values schema", "dst", dst)
1130+
b, err := json.MarshalIndent(merged, "", " ")
1131+
if err != nil {
1132+
return fmt.Errorf("failed to marshal merged values schema: %w", err)
1133+
}
1134+
if err := os.WriteFile(dst, b, helpers.ReadWriteUser); err != nil {
1135+
return fmt.Errorf("failed to write merged values schema: %w", err)
10621136
}
1137+
return nil
1138+
}
10631139

1140+
// schemaVersion extracts the "$schema" version URI from a schema map, returning "" if absent or not a string.
1141+
func schemaVersion(s map[string]any) string {
1142+
if v, ok := s["$schema"].(string); ok {
1143+
return v
1144+
}
1145+
return ""
1146+
}
1147+
1148+
// checkCompatibleVersion errors when the accumulated merged schema and an incoming schema declare
1149+
// different "$schema" versions, preventing silent cross-version merge bugs.
1150+
func checkCompatibleVersion(accumulated, incoming map[string]any, incomingLabel string) error {
1151+
a := schemaVersion(accumulated)
1152+
b := schemaVersion(incoming)
1153+
if a != b {
1154+
return fmt.Errorf("cannot merge schemas with different versions: accumulated schema uses %q but %s declares %q", a, incomingLabel, b)
1155+
}
10641156
return nil
10651157
}
10661158

0 commit comments

Comments
 (0)