fix(schema): resolve $ref for aliased subchart dependencies

After `processDependencyEnabled`, aliased subcharts have their
`Metadata.Name` rewritten to the alias, but the on-disk directory
retains the original chart name. The previous code used `sub.Name()`
directly to build the filesystem path, causing `$ref` resolution to
look in a non-existent directory (e.g., `charts/database/` instead
of `charts/mysql/`).

Add `resolveSubchartDir` which first tries a direct name match, then
falls back to scanning `charts/` subdirectories by comparing
`values.schema.json` content byte-for-byte with the in-memory schema.

Addresses https://github.com/helm/helm/pull/31274#discussion_r3224792920

Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
pull/31274/head
Benoit Tigeot 3 months ago
parent 8b5d094f19
commit 4ae26af132
No known key found for this signature in database
GPG Key ID: 8E6D4FC8AEBDA62C

@ -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.

@ -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)
}
}

Loading…
Cancel
Save