diff --git a/internal/chart/v3/lint/rules/template.go b/internal/chart/v3/lint/rules/template.go index 35e4940ab..22f2e39bf 100644 --- a/internal/chart/v3/lint/rules/template.go +++ b/internal/chart/v3/lint/rules/template.go @@ -97,7 +97,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string return } - valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidationAndPath(chart, cvals, options, caps, skipSchemaValidation, linter.ChartDir) if err != nil { linter.RunLinterRule(support.ErrorSev, fpath, err) return diff --git a/internal/chart/v3/lint/rules/values.go b/internal/chart/v3/lint/rules/values.go index b4a2edb0c..871d26274 100644 --- a/internal/chart/v3/lint/rules/values.go +++ b/internal/chart/v3/lint/rules/values.go @@ -42,7 +42,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]any, return } - linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation)) + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(linter.ChartDir, valueOverrides, skipSchemaValidation)) } func validateValuesFileExistence(valuesPath string) error { @@ -53,7 +53,10 @@ func validateValuesFileExistence(valuesPath string) error { return nil } -func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaValidation bool) error { +func validateValuesFile(chartDir string, overrides map[string]any, skipSchemaValidation bool) error { + valuesPath := filepath.Join(chartDir, "values.yaml") + schemaPath := filepath.Join(chartDir, "values.schema.json") + values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) @@ -67,8 +70,6 @@ func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaV coalescedValues := util.CoalesceTables(make(map[string]any, len(overrides)), overrides) coalescedValues = util.CoalesceTables(coalescedValues, values) - ext := filepath.Ext(valuesPath) - schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" schema, err := os.ReadFile(schemaPath) if len(schema) == 0 { return nil @@ -78,7 +79,7 @@ func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaV } if !skipSchemaValidation { - return util.ValidateAgainstSingleSchema(coalescedValues, schema) + return util.ValidateAgainstSingleSchemaWithPath(coalescedValues, schema, schemaPath) } return nil diff --git a/internal/chart/v3/lint/rules/values_test.go b/internal/chart/v3/lint/rules/values_test.go index afc544ebd..b40c7b7de 100644 --- a/internal/chart/v3/lint/rules/values_test.go +++ b/internal/chart/v3/lint/rules/values_test.go @@ -66,8 +66,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) { not:well[]{}formed ` tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) - valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]any{}, false); err == nil { + if err := validateValuesFile(tmpdir, map[string]any{}, false); err == nil { t.Fatal("expected values file to fail parsing") } } @@ -77,8 +76,7 @@ func TestValidateValuesFileSchema(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]any{}, false); err != nil { + if err := validateValuesFile(tmpdir, map[string]any{}, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -89,9 +87,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - - err := validateValuesFile(valfile, map[string]any{}, false) + err := validateValuesFile(tmpdir, map[string]any{}, false) if err == nil { t.Fatal("expected values file to fail parsing") } @@ -105,9 +101,7 @@ func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - - err := validateValuesFile(valfile, map[string]any{}, true) + err := validateValuesFile(tmpdir, map[string]any{}, true) if err != nil { t.Fatal("expected values file to pass parsing because of skipSchemaValidation") } @@ -121,8 +115,7 @@ func TestValidateValuesFileSchemaOverrides(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, overrides, false); err != nil { + if err := validateValuesFile(tmpdir, overrides, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -157,9 +150,7 @@ func TestValidateValuesFile(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(tt.yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - - err := validateValuesFile(valfile, tt.overrides, false) + err := validateValuesFile(tmpdir, tt.overrides, false) switch { case err != nil && tt.errorMessage == "": diff --git a/pkg/action/install.go b/pkg/action/install.go index 50df13c05..aee1ec25a 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -127,6 +127,9 @@ type Install struct { // Used by helm template to add the release as part of OutputDir path // OutputDir/ UseReleaseName bool + // ChartDir is the local directory path of the chart, used for resolving + // relative $ref in JSON schemas. Empty for remote charts. + ChartDir string // TakeOwnership will ignore the check for helm annotations and take ownership of the resources. TakeOwnership bool PostRenderer postrenderer.PostRenderer @@ -358,7 +361,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st IsInstall: !isUpgrade, IsUpgrade: isUpgrade, } - valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chrt, vals, options, caps, i.SkipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidationAndPath(chrt, vals, options, caps, i.SkipSchemaValidation, i.ChartDir) if err != nil { return nil, err } diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go index 6ee1e07fa..08329ad6c 100644 --- a/pkg/action/lint_test.go +++ b/pkg/action/lint_test.go @@ -71,6 +71,14 @@ func TestLintChart(t *testing.T) { name: "chart-with-schema", chartPath: "testdata/charts/chart-with-schema", }, + { + name: "chart-with-schema-ref", + chartPath: "testdata/charts/chart-with-schema-ref", + }, + { + name: "archived-chart-with-schema-ref", + chartPath: "testdata/charts/chart-with-schema-ref.tgz", + }, { name: "chart-with-schema-negative", chartPath: "testdata/charts/chart-with-schema-negative", diff --git a/pkg/action/testdata/charts/chart-with-schema-ref.tgz b/pkg/action/testdata/charts/chart-with-schema-ref.tgz new file mode 100644 index 000000000..da12c8d09 Binary files /dev/null and b/pkg/action/testdata/charts/chart-with-schema-ref.tgz differ diff --git a/pkg/action/testdata/charts/chart-with-schema-ref/Chart.yaml b/pkg/action/testdata/charts/chart-with-schema-ref/Chart.yaml new file mode 100644 index 000000000..c344a04d9 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-ref/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: chart-with-schema-ref +version: 0.1.0 diff --git a/pkg/action/testdata/charts/chart-with-schema-ref/name.schema.json b/pkg/action/testdata/charts/chart-with-schema-ref/name.schema.json new file mode 100644 index 000000000..290e9cca5 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-ref/name.schema.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" +} diff --git a/pkg/action/testdata/charts/chart-with-schema-ref/values.schema.json b/pkg/action/testdata/charts/chart-with-schema-ref/values.schema.json new file mode 100644 index 000000000..e253c4c7e --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-ref/values.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { "$ref": "name.schema.json" } + } +} diff --git a/pkg/action/testdata/charts/chart-with-schema-ref/values.yaml b/pkg/action/testdata/charts/chart-with-schema-ref/values.yaml new file mode 100644 index 000000000..0b9fc7e3a --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-ref/values.yaml @@ -0,0 +1 @@ +name: "test" diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 0f360fe37..39d002085 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -113,6 +113,9 @@ type Upgrade struct { HideNotes bool // SkipSchemaValidation determines if JSON schema validation is disabled. SkipSchemaValidation bool + // ChartDir is the local directory path of the chart, used for resolving + // relative $ref in JSON schemas. Empty for remote charts. + ChartDir string // Description is the description of this operation Description string Labels map[string]string @@ -291,7 +294,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str if err != nil { return nil, nil, false, err } - valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidationAndPath(chart, vals, options, caps, u.SkipSchemaValidation, u.ChartDir) if err != nil { return nil, nil, false, err } diff --git a/pkg/chart/common/util/jsonschema.go b/pkg/chart/common/util/jsonschema.go index 63ca0c274..752184c7d 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" + "path/filepath" "strings" "sync" "time" @@ -74,14 +75,39 @@ func newHTTPURLLoader() *HTTPURLLoader { // ValidateAgainstSchema checks that values does not violate the structure laid out in schema func ValidateAgainstSchema(ch chart.Charter, values map[string]any) error { + return ValidateAgainstSchemaWithPath(ch, values, "") +} + +func ValidateAgainstSchemaWithPath(ch chart.Charter, values map[string]any, chartDir string) error { chrt, err := chart.NewAccessor(ch) if err != nil { return err } + + var absChartPath string + if chartDir != "" { + var err error + absChartPath, err = filepath.Abs(chartDir) + if err != nil { + return err + } + } + var sb strings.Builder if chrt.Schema() != nil { slog.Debug("chart name", "chart-name", chrt.Name()) - err := ValidateAgainstSingleSchema(values, chrt.Schema()) + + var schemaPath string + if absChartPath != "" { + // Use the chart directory for $ref resolution + schemaPath = filepath.Join(absChartPath, "values.schema.json") + } else { + // No chart directory (e.g., chart loaded from .tgz archive). + // Use a synthetic path - $ref resolution will not work, but main schema validation will. + schemaPath = "/values.schema.json" + } + + err := ValidateAgainstSingleSchemaWithPath(values, chrt.Schema(), schemaPath) if err != nil { fmt.Fprintf(&sb, "%s:\n", chrt.Name()) sb.WriteString(err.Error()) @@ -108,7 +134,12 @@ func ValidateAgainstSchema(ch chart.Charter, values map[string]any) error { continue } - if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { + var subchartPath string + if absChartPath != "" { + subchartPath = filepath.Join(absChartPath, "charts", sub.Name()) + } + // 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()) } } @@ -122,6 +153,12 @@ func ValidateAgainstSchema(ch chart.Charter, values map[string]any) error { // ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema func ValidateAgainstSingleSchema(values common.Values, schemaJSON []byte) (reterr error) { + return ValidateAgainstSingleSchemaWithPath(values, schemaJSON, "/values.schema.json") +} + +// ValidateAgainstSingleSchemaWithPath checks that values does not violate the structure laid out in this schema. +// schemaPath is the absolute path to the schema file, used to resolve relative $ref references. +func ValidateAgainstSingleSchemaWithPath(values common.Values, schemaJSON []byte, schemaPath string) (reterr error) { defer func() { if r := recover(); r != nil { reterr = fmt.Errorf("unable to validate schema: %s", r) @@ -146,12 +183,14 @@ func ValidateAgainstSingleSchema(values common.Values, schemaJSON []byte) (reter compiler := jsonschema.NewCompiler() compiler.UseLoader(loader) - err = compiler.AddResource("file:///values.schema.json", schema) + + schemaURL := fmt.Sprintf("file://%s", schemaPath) + err = compiler.AddResource(schemaURL, schema) if err != nil { return err } - validator, err := compiler.Compile("file:///values.schema.json") + validator, err := compiler.Compile(schemaURL) if err != nil { return err } @@ -209,7 +248,12 @@ func (e JSONSchemaValidationError) Error() string { // This string prefixes all of our error details. Further up the stack of helm error message // building more detail is provided to users. This is removed. - errStr = strings.TrimPrefix(errStr, "jsonschema validation failed with 'file:///values.schema.json#'\n") + // Remove the "jsonschema validation failed with 'file://...#'" line regardless of the path + if strings.HasPrefix(errStr, "jsonschema validation failed with 'file://") { + if idx := strings.Index(errStr, "#'\n"); idx != -1 { + errStr = errStr[idx+3:] // Skip past "#'\n" + } + } // The extra new line is needed for when there are sub-charts. return errStr + "\n" diff --git a/pkg/chart/common/util/jsonschema_test.go b/pkg/chart/common/util/jsonschema_test.go index 838d152a1..6fed85462 100644 --- a/pkg/chart/common/util/jsonschema_test.go +++ b/pkg/chart/common/util/jsonschema_test.go @@ -20,6 +20,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" @@ -177,11 +178,12 @@ func TestValidateAgainstSchemaNegative(t *testing.T) { errString = err.Error() } - expectedErrString := `subchart: -- at '': missing property 'age' -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + expectedValidationError := "missing property 'age'" + if !strings.Contains(errString, "subchart:") { + t.Errorf("Error string should contain 'subchart:', got: %s", errString) + } + if !strings.Contains(errString, expectedValidationError) { + t.Errorf("Error string should contain '%s', got: %s", expectedValidationError, errString) } } @@ -241,12 +243,63 @@ func TestValidateAgainstSchema2020Negative(t *testing.T) { errString = err.Error() } - expectedErrString := `subchart: -- at '/data': no items match contains schema - - at '/data/0': got number, want string -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + expectedValidationErrors := []string{ + "no items match contains schema", + "got number, want string", + } + if !strings.Contains(errString, "subchart:") { + t.Errorf("Error string should contain 'subchart:', got: %s", errString) + } + for _, expectedErr := range expectedValidationErrors { + if !strings.Contains(errString, expectedErr) { + t.Errorf("Error string should contain '%s', got: %s", expectedErr, errString) + } + } +} + +// TestValidateWithRelativeSchemaReferences tests schema validation with relative $ref paths +// This mimics the behavior of "helm lint ." where the schema is in the current directory +func TestValidateWithRelativeSchemaReferencesCurrentDir(t *testing.T) { + values, err := common.ReadValuesFile("./testdata/current-dir-test/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schemaPath := "./testdata/current-dir-test/values.schema.json" + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("Error reading JSON schema file: %s", err) + } + + absSchemaPath, err := filepath.Abs(schemaPath) + if err != nil { + t.Fatalf("Error getting absolute path: %s", err) + } + + if err := ValidateAgainstSingleSchemaWithPath(values, schema, absSchemaPath); err != nil { + t.Errorf("Error validating Values against Schema with relative references: %s", err) + } +} + +// TestValidateWithRelativeSchemaReferencesSubfolder tests schema validation with relative $ref paths +// This mimics the behavior of "helm lint subfolder" where the schema is in a subdirectory +func TestValidateWithRelativeSchemaReferencesSubfolder(t *testing.T) { + values, err := common.ReadValuesFile("./testdata/subdir-test/subfolder/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schemaPath := "./testdata/subdir-test/subfolder/values.schema.json" + schema, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("Error reading JSON schema file: %s", err) + } + + absSchemaPath, err := filepath.Abs(schemaPath) + if err != nil { + t.Fatalf("Error getting absolute path: %s", err) + } + + if err := ValidateAgainstSingleSchemaWithPath(values, schema, absSchemaPath); err != nil { + t.Errorf("Error validating Values against Schema with relative references from subfolder: %s", err) } } diff --git a/pkg/chart/common/util/testdata/current-dir-test/base.schema.json b/pkg/chart/common/util/testdata/current-dir-test/base.schema.json new file mode 100644 index 000000000..fddf95114 --- /dev/null +++ b/pkg/chart/common/util/testdata/current-dir-test/base.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] +} diff --git a/pkg/chart/common/util/testdata/current-dir-test/test-values.yaml b/pkg/chart/common/util/testdata/current-dir-test/test-values.yaml new file mode 100644 index 000000000..817fa4bd1 --- /dev/null +++ b/pkg/chart/common/util/testdata/current-dir-test/test-values.yaml @@ -0,0 +1,3 @@ +user: + name: "John Doe" +age: 30 diff --git a/pkg/chart/common/util/testdata/current-dir-test/values.schema.json b/pkg/chart/common/util/testdata/current-dir-test/values.schema.json new file mode 100644 index 000000000..a4cf4a53d --- /dev/null +++ b/pkg/chart/common/util/testdata/current-dir-test/values.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "user": { + "$ref": "./base.schema.json" + }, + "age": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["user", "age"] +} diff --git a/pkg/chart/common/util/testdata/subdir-test/shared.schema.json b/pkg/chart/common/util/testdata/subdir-test/shared.schema.json new file mode 100644 index 000000000..2bb750058 --- /dev/null +++ b/pkg/chart/common/util/testdata/subdir-test/shared.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "config": { + "type": "string" + } + }, + "required": ["config"] +} diff --git a/pkg/chart/common/util/testdata/subdir-test/subfolder/test-values.yaml b/pkg/chart/common/util/testdata/subdir-test/subfolder/test-values.yaml new file mode 100644 index 000000000..b888e30f0 --- /dev/null +++ b/pkg/chart/common/util/testdata/subdir-test/subfolder/test-values.yaml @@ -0,0 +1,3 @@ +appConfig: + config: "production" +replicas: 3 diff --git a/pkg/chart/common/util/testdata/subdir-test/subfolder/values.schema.json b/pkg/chart/common/util/testdata/subdir-test/subfolder/values.schema.json new file mode 100644 index 000000000..9a50779b7 --- /dev/null +++ b/pkg/chart/common/util/testdata/subdir-test/subfolder/values.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "appConfig": { + "$ref": "../shared.schema.json" + }, + "replicas": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["appConfig", "replicas"] +} diff --git a/pkg/chart/common/util/values.go b/pkg/chart/common/util/values.go index 95ac7ba4d..492e99f33 100644 --- a/pkg/chart/common/util/values.go +++ b/pkg/chart/common/util/values.go @@ -34,6 +34,12 @@ func ToRenderValues(chrt chart.Charter, chrtVals map[string]any, options common. // // This takes both ReleaseOptions and Capabilities to merge into the render values. func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string]any, options common.ReleaseOptions, caps *common.Capabilities, skipSchemaValidation bool) (common.Values, error) { + return ToRenderValuesWithSchemaValidationAndPath(chrt, chrtVals, options, caps, skipSchemaValidation, "") +} + +// ToRenderValuesWithSchemaValidationAndPath is like ToRenderValuesWithSchemaValidation but accepts chartDir +// for resolving relative $ref in JSON schemas. +func ToRenderValuesWithSchemaValidationAndPath(chrt chart.Charter, chrtVals map[string]any, options common.ReleaseOptions, caps *common.Capabilities, skipSchemaValidation bool, chartDir string) (common.Values, error) { if caps == nil { caps = common.DefaultCapabilities } @@ -60,7 +66,7 @@ func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string] } if !skipSchemaValidation { - if err := ValidateAgainstSchema(chrt, vals); err != nil { + if err := ValidateAgainstSchemaWithPath(chrt, vals, chartDir); err != nil { return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err) } } diff --git a/pkg/chart/v2/lint/rules/template.go b/pkg/chart/v2/lint/rules/template.go index 43665aa3a..97a403b2e 100644 --- a/pkg/chart/v2/lint/rules/template.go +++ b/pkg/chart/v2/lint/rules/template.go @@ -128,7 +128,7 @@ func (t *templateLinter) Lint() { return } - valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, t.skipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidationAndPath(chart, cvals, options, caps, t.skipSchemaValidation, t.linter.ChartDir) if err != nil { t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) return diff --git a/pkg/chart/v2/lint/rules/values.go b/pkg/chart/v2/lint/rules/values.go index 2c766068c..205d820ce 100644 --- a/pkg/chart/v2/lint/rules/values.go +++ b/pkg/chart/v2/lint/rules/values.go @@ -42,7 +42,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]any, return } - linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation)) + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(linter.ChartDir, valueOverrides, skipSchemaValidation)) } func validateValuesFileExistence(valuesPath string) error { @@ -53,7 +53,10 @@ func validateValuesFileExistence(valuesPath string) error { return nil } -func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaValidation bool) error { +func validateValuesFile(chartDir string, overrides map[string]any, skipSchemaValidation bool) error { + valuesPath := filepath.Join(chartDir, "values.yaml") + schemaPath := filepath.Join(chartDir, "values.schema.json") + values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) @@ -67,8 +70,6 @@ func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaV coalescedValues := util.CoalesceTables(make(map[string]any, len(overrides)), overrides) coalescedValues = util.CoalesceTables(coalescedValues, values) - ext := filepath.Ext(valuesPath) - schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" schema, err := os.ReadFile(schemaPath) if len(schema) == 0 { return nil @@ -78,7 +79,7 @@ func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaV } if !skipSchemaValidation { - return util.ValidateAgainstSingleSchema(coalescedValues, schema) + return util.ValidateAgainstSingleSchemaWithPath(coalescedValues, schema, schemaPath) } return nil diff --git a/pkg/chart/v2/lint/rules/values_test.go b/pkg/chart/v2/lint/rules/values_test.go index afc544ebd..b40c7b7de 100644 --- a/pkg/chart/v2/lint/rules/values_test.go +++ b/pkg/chart/v2/lint/rules/values_test.go @@ -66,8 +66,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) { not:well[]{}formed ` tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) - valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]any{}, false); err == nil { + if err := validateValuesFile(tmpdir, map[string]any{}, false); err == nil { t.Fatal("expected values file to fail parsing") } } @@ -77,8 +76,7 @@ func TestValidateValuesFileSchema(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]any{}, false); err != nil { + if err := validateValuesFile(tmpdir, map[string]any{}, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -89,9 +87,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - - err := validateValuesFile(valfile, map[string]any{}, false) + err := validateValuesFile(tmpdir, map[string]any{}, false) if err == nil { t.Fatal("expected values file to fail parsing") } @@ -105,9 +101,7 @@ func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - - err := validateValuesFile(valfile, map[string]any{}, true) + err := validateValuesFile(tmpdir, map[string]any{}, true) if err != nil { t.Fatal("expected values file to pass parsing because of skipSchemaValidation") } @@ -121,8 +115,7 @@ func TestValidateValuesFileSchemaOverrides(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, overrides, false); err != nil { + if err := validateValuesFile(tmpdir, overrides, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -157,9 +150,7 @@ func TestValidateValuesFile(t *testing.T) { tmpdir := ensure.TempFile(t, "values.yaml", []byte(tt.yaml)) createTestingSchema(t, tmpdir) - valfile := filepath.Join(tmpdir, "values.yaml") - - err := validateValuesFile(valfile, tt.overrides, false) + err := validateValuesFile(tmpdir, tt.overrides, false) switch { case err != nil && tt.errorMessage == "": diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index ed10513c9..8896c8b93 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -249,6 +249,13 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options return nil, err } + // Only set ChartDir for directory-based charts to enable $ref resolution. + // Archived charts (.tgz) are loaded into memory without filesystem extraction, + // so $ref resolution is not supported for them. + if fi, err := os.Stat(cp); err == nil && fi.IsDir() { + client.ChartDir = cp + } + slog.Debug("Chart path", "path", cp) p := getter.All(settings) diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 8d3435e03..c4ef2950a 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -231,6 +231,11 @@ func TestInstall(t *testing.T) { cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart --set lastname=doe --set subchart-with-schema.age=-25 --skip-schema-validation", golden: "output/schema.txt", }, + { + name: "install with schema file containing $ref", + cmd: "install reftest testdata/testcharts/chart-with-schema-ref", + golden: "output/schema-ref.txt", + }, // Install deprecated chart { name: "install with warning about deprecated chart", diff --git a/pkg/cmd/template_test.go b/pkg/cmd/template_test.go index 7391781f6..dbac7c420 100644 --- a/pkg/cmd/template_test.go +++ b/pkg/cmd/template_test.go @@ -166,6 +166,11 @@ func TestTemplateCmd(t *testing.T) { cmd: fmt.Sprintf("template '%s' -f %s/extra_values.yaml", chartPath, chartPath), golden: "output/template-subchart-cm-set-file.txt", }, + { + name: "template with schema file containing $ref", + cmd: "template reftest testdata/testcharts/chart-with-schema-ref", + golden: "output/template-schema-ref.txt", + }, } runTestCmd(t, tests) } diff --git a/pkg/cmd/testdata/output/schema-ref.txt b/pkg/cmd/testdata/output/schema-ref.txt new file mode 100644 index 000000000..fa6015315 --- /dev/null +++ b/pkg/cmd/testdata/output/schema-ref.txt @@ -0,0 +1,7 @@ +NAME: reftest +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +TEST SUITE: None diff --git a/pkg/cmd/testdata/output/template-schema-ref.txt b/pkg/cmd/testdata/output/template-schema-ref.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/pkg/cmd/testdata/output/template-schema-ref.txt @@ -0,0 +1 @@ + diff --git a/pkg/cmd/testdata/output/upgrade-schema-ref.txt b/pkg/cmd/testdata/output/upgrade-schema-ref.txt new file mode 100644 index 000000000..cceed6919 --- /dev/null +++ b/pkg/cmd/testdata/output/upgrade-schema-ref.txt @@ -0,0 +1,8 @@ +Release "reftest" has been upgraded. Happy Helming! +NAME: reftest +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 2 +DESCRIPTION: Upgrade complete +TEST SUITE: None diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-ref/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/Chart.yaml new file mode 100644 index 000000000..c344a04d9 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: chart-with-schema-ref +version: 0.1.0 diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-ref/name.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/name.schema.json new file mode 100644 index 000000000..290e9cca5 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/name.schema.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" +} diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-ref/values.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/values.schema.json new file mode 100644 index 000000000..e253c4c7e --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/values.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { "$ref": "name.schema.json" } + } +} diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-ref/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/values.yaml new file mode 100644 index 000000000..0b9fc7e3a --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-schema-ref/values.yaml @@ -0,0 +1 @@ +name: "test" diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index b71c4ae2d..386107c52 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -187,6 +187,13 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } + // Only set ChartDir for directory-based charts to enable $ref resolution. + // Archived charts (.tgz) are loaded into memory without filesystem extraction, + // so $ref resolution is not supported for them. + if fi, err := os.Stat(chartPath); err == nil && fi.IsDir() { + client.ChartDir = chartPath + } + p := getter.All(settings) vals, err := valueOpts.MergeValues(p) if err != nil { diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index f96f6ec0d..ba4353f2c 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -190,6 +190,12 @@ func TestUpgradeCmd(t *testing.T) { golden: "output/upgrade-uninstalled-with-keep-history.txt", rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, rcommon.StatusUninstalled)}, }, + { + name: "upgrade with schema file containing $ref", + cmd: "upgrade reftest testdata/testcharts/chart-with-schema-ref", + golden: "output/upgrade-schema-ref.txt", + rels: []*release.Release{relMock("reftest", 1, ch)}, + }, } runTestCmd(t, tests) }