diff --git a/pkg/chart/common/util/jsonschema.go b/pkg/chart/common/util/jsonschema.go index 94a3171d1..d7588a67f 100644 --- a/pkg/chart/common/util/jsonschema.go +++ b/pkg/chart/common/util/jsonschema.go @@ -23,6 +23,7 @@ import ( "fmt" "log/slog" "net/http" + "os" "path/filepath" "strings" "sync" @@ -139,9 +140,12 @@ func ValidateAgainstSchemaWithPath(ch chart.Charter, values map[string]any, char var subchartPath string if absChartPath != "" { - subchartPath = filepath.Join(absChartPath, "charts", sub.Name()) + subchartPath = resolveSubchartDir( + filepath.Join(absChartPath, "charts"), + sub.Name(), + sub.Schema(), + ) } - // If absChartPath is empty (archived chart), pass empty string to disable $ref resolution for subcharts too if err := ValidateAgainstSchemaWithPath(subchart, subchartValues, subchartPath); err != nil { sb.WriteString(err.Error()) } @@ -235,6 +239,41 @@ func (l urnLoader) Load(urlStr string) (any, error) { return jsonschema.UnmarshalJSON(strings.NewReader("true")) } +// resolveSubchartDir finds the on-disk directory for a subchart under chartsDir. +// Returns "" when no directory can be found, which disables $ref resolution. +func resolveSubchartDir(chartsDir, effectiveName string, schema []byte) string { + // Direct match; handles the common non-aliased case in one syscall. + candidate := filepath.Join(chartsDir, effectiveName) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + + // The effective name didn't match a directory likely an alias. + // Scan charts/ subdirectories and match by schema content. + if len(schema) == 0 { + return "" + } + entries, err := os.ReadDir(chartsDir) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(chartsDir, e.Name(), "values.schema.json")) + if err != nil { + continue + } + // getAliasDependency shallow-copies the chart, so schema bytes in memory + // are identical to the file originally loaded from this directory. + if bytes.Equal(data, schema) { + return filepath.Join(chartsDir, e.Name()) + } + } + return "" +} + // Note, JSONSchemaValidationError is used to wrap the error from the underlying // validation package so that Helm has a clean interface and the validation package // could be replaced without changing the Helm SDK API. diff --git a/pkg/chart/common/util/jsonschema_test.go b/pkg/chart/common/util/jsonschema_test.go index 33e82cf5f..c0962b5bc 100644 --- a/pkg/chart/common/util/jsonschema_test.go +++ b/pkg/chart/common/util/jsonschema_test.go @@ -442,3 +442,170 @@ func TestValidateAgainstSchema_InvalidSubchartValuesType_NoPanic(t *testing.T) { t.Fatalf("expected an error when subchart values have invalid type, got nil") } } + +// Test that $ref resolution works for aliased subcharts. +// When a subchart has an alias (e.g., mysql aliased as "database"), +// processDependencyEnabled rewrites Metadata.Name to the alias, +// but the on-disk directory retains the original name (charts/mysql/). +// The schema validator must find the correct directory to resolve $ref. +func TestValidateAgainstSchemaWithPath_AliasedSubchartRef(t *testing.T) { + tmpDir := t.TempDir() + + // On-disk layout: charts/mysql/ contains the schema files. + // The directory name is the ORIGINAL chart name, not the alias. + mysqlDir := filepath.Join(tmpDir, "charts", "mysql") + if err := os.MkdirAll(mysqlDir, 0o755); err != nil { + t.Fatal(err) + } + + baseSchema := []byte(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "port": { "type": "integer", "minimum": 1 } + }, + "required": ["port"] +}`) + if err := os.WriteFile(filepath.Join(mysqlDir, "base.schema.json"), baseSchema, 0o644); err != nil { + t.Fatal(err) + } + + subchartSchemaBytes := []byte(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "config": { "$ref": "./base.schema.json" } + }, + "required": ["config"] +}`) + if err := os.WriteFile(filepath.Join(mysqlDir, "values.schema.json"), subchartSchemaBytes, 0o644); err != nil { + t.Fatal(err) + } + + // In-memory chart: Metadata.Name is the ALIAS ("database"), + // simulating what processDependencyEnabled does after loading. + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "database"}, + Schema: subchartSchemaBytes, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{Name: "testchart"}, + } + chrt.AddDependency(subchart) + + vals := map[string]any{ + "database": map[string]any{ + "config": map[string]any{ + "port": 3306, + }, + }, + } + + if err := ValidateAgainstSchemaWithPath(chrt, vals, tmpDir); err != nil { + t.Errorf("expected no error for valid values with aliased subchart $ref, got: %s", err) + } +} + +// Test that $ref resolution works when multiple aliases point to the same chart. +// Both aliased subcharts should resolve $ref through the single on-disk directory. +func TestValidateAgainstSchemaWithPath_MultipleAliasesSameChart(t *testing.T) { + tmpDir := t.TempDir() + + mysqlDir := filepath.Join(tmpDir, "charts", "mysql") + if err := os.MkdirAll(mysqlDir, 0o755); err != nil { + t.Fatal(err) + } + + baseSchema := []byte(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "port": { "type": "integer", "minimum": 1 } + }, + "required": ["port"] +}`) + if err := os.WriteFile(filepath.Join(mysqlDir, "base.schema.json"), baseSchema, 0o644); err != nil { + t.Fatal(err) + } + + subchartSchemaBytes := []byte(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "config": { "$ref": "./base.schema.json" } + }, + "required": ["config"] +}`) + if err := os.WriteFile(filepath.Join(mysqlDir, "values.schema.json"), subchartSchemaBytes, 0o644); err != nil { + t.Fatal(err) + } + + // Two aliased subcharts from the same original chart + primary := &chart.Chart{ + Metadata: &chart.Metadata{Name: "primary"}, + Schema: subchartSchemaBytes, + } + replica := &chart.Chart{ + Metadata: &chart.Metadata{Name: "replica"}, + Schema: subchartSchemaBytes, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{Name: "testchart"}, + } + chrt.AddDependency(primary) + chrt.AddDependency(replica) + + vals := map[string]any{ + "primary": map[string]any{ + "config": map[string]any{"port": 3306}, + }, + "replica": map[string]any{ + "config": map[string]any{"port": 3307}, + }, + } + + if err := ValidateAgainstSchemaWithPath(chrt, vals, tmpDir); err != nil { + t.Errorf("expected no error for multiple aliases of same chart, got: %s", err) + } +} + +// Test that validation proceeds gracefully when an aliased subchart has no +// matching directory on disk (e.g., the subchart is an archived .tgz). +// $ref resolution is disabled but main schema validation still works. +func TestValidateAgainstSchemaWithPath_AliasedSubchartNoDir(t *testing.T) { + tmpDir := t.TempDir() + + // Create empty charts/ directory — no subdirectory matching any name + if err := os.MkdirAll(filepath.Join(tmpDir, "charts"), 0o755); err != nil { + t.Fatal(err) + } + + // Schema without $ref — validates independently + subchartSchemaBytes := []byte(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "port": { "type": "integer" } + }, + "required": ["port"] +}`) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "database"}, + Schema: subchartSchemaBytes, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{Name: "testchart"}, + } + chrt.AddDependency(subchart) + + vals := map[string]any{ + "database": map[string]any{ + "port": 5432, + }, + } + + if err := ValidateAgainstSchemaWithPath(chrt, vals, tmpDir); err != nil { + t.Errorf("expected no error when aliased subchart dir missing (graceful fallback), got: %s", err) + } +}