diff --git a/Gopkg.lock b/Gopkg.lock index c150a63cf57..7e451496832 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -835,6 +835,30 @@ revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" version = "v1.3.0" +[[projects]] + branch = "master" + digest = "1:f4e5276a3b356f4692107047fd2890f2fe534f4feeb6b1fd2f6dfbd87f1ccf54" + name = "github.com/xeipuuv/gojsonpointer" + packages = ["."] + pruneopts = "UT" + revision = "4e3ac2762d5f479393488629ee9370b50873b3a6" + +[[projects]] + branch = "master" + digest = "1:dc6a6c28ca45d38cfce9f7cb61681ee38c5b99ec1425339bfc1e1a7ba769c807" + name = "github.com/xeipuuv/gojsonreference" + packages = ["."] + pruneopts = "UT" + revision = "bd5ef7bd5415a7ac448318e64f11a24cd21e594b" + +[[projects]] + digest = "1:1c898ea6c30c16e8d55fdb6fe44c4bee5f9b7d68aa260cfdfc3024491dcc7bea" + name = "github.com/xeipuuv/gojsonschema" + packages = ["."] + pruneopts = "UT" + revision = "f971f3cd73b2899de6923801c147f075263e0c50" + version = "v1.1.0" + [[projects]] digest = "1:340553b2fdaab7d53e63fd40f8ed82203bdd3274253055bdb80a46828482ef81" name = "github.com/xenolf/lego" @@ -1676,6 +1700,7 @@ "github.com/spf13/pflag", "github.com/stretchr/testify/assert", "github.com/stretchr/testify/suite", + "github.com/xeipuuv/gojsonschema", "golang.org/x/crypto/openpgp", "golang.org/x/crypto/openpgp/clearsign", "golang.org/x/crypto/openpgp/errors", diff --git a/Gopkg.toml b/Gopkg.toml index ec0fdeae36c..fe26c00101f 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -107,3 +107,6 @@ go-tests = true unused-packages = true +[[constraint]] + name = "github.com/xeipuuv/gojsonschema" + version = "1.1.0" diff --git a/cmd/helm/install_test.go b/cmd/helm/install_test.go index 05ec6ea1ab9..f6da1449677 100644 --- a/cmd/helm/install_test.go +++ b/cmd/helm/install_test.go @@ -136,6 +136,53 @@ func TestInstall(t *testing.T) { wantError: true, golden: "output/install-chart-bad-type.txt", }, + // Install, values from yaml, schematized + { + name: "install with schema file", + cmd: "install schema testdata/testcharts/chart-with-schema", + golden: "output/schema.txt", + }, + // Install, values from yaml, schematized with errors + { + name: "install with schema file, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema-negative", + wantError: true, + golden: "output/schema-negative.txt", + }, + // Install, values from yaml, extra values from yaml, schematized with errors + { + name: "install with schema file, extra values from yaml, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema -f testdata/testcharts/chart-with-schema/extra-values.yaml", + wantError: true, + golden: "output/schema-negative.txt", + }, + // Install, values from yaml, extra values from cli, schematized with errors + { + name: "install with schema file, extra values from cli, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema --set age=-5", + wantError: true, + golden: "output/schema-negative-cli.txt", + }, + // Install with subchart, values from yaml, schematized with errors + { + name: "install with schema file and schematized subchart, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart", + wantError: true, + golden: "output/subchart-schema-negative.txt", + }, + // Install with subchart, values from yaml, extra values from cli, schematized with errors + { + name: "install with schema file and schematized subchart, extra values from cli", + cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart --set lastname=doe --set subchart-with-schema.age=25", + golden: "output/subchart-schema-cli.txt", + }, + // Install with subchart, values from yaml, extra values from cli, schematized with errors + { + name: "install with schema file and schematized subchart, extra values from cli, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart --set lastname=doe --set subchart-with-schema.age=-25", + wantError: true, + golden: "output/subchart-schema-cli-negative.txt", + }, } runTestActionCmd(t, tests) diff --git a/cmd/helm/testdata/output/schema-negative-cli.txt b/cmd/helm/testdata/output/schema-negative-cli.txt new file mode 100644 index 00000000000..26bc92b1bdb --- /dev/null +++ b/cmd/helm/testdata/output/schema-negative-cli.txt @@ -0,0 +1,4 @@ +Error: values don't meet the specifications of the schema(s) in the following chart(s): +empty: +- age: Must be greater than or equal to 0/1 + diff --git a/cmd/helm/testdata/output/schema-negative.txt b/cmd/helm/testdata/output/schema-negative.txt new file mode 100644 index 00000000000..2ea97b7d000 --- /dev/null +++ b/cmd/helm/testdata/output/schema-negative.txt @@ -0,0 +1,5 @@ +Error: values don't meet the specifications of the schema(s) in the following chart(s): +empty: +- (root): employmentInfo is required +- age: Must be greater than or equal to 0/1 + diff --git a/cmd/helm/testdata/output/schema.txt b/cmd/helm/testdata/output/schema.txt new file mode 100644 index 00000000000..f694bfdf14b --- /dev/null +++ b/cmd/helm/testdata/output/schema.txt @@ -0,0 +1,5 @@ +NAME: schema +LAST DEPLOYED: 1977-09-02 22:04:05 +0000 UTC +NAMESPACE: default +STATUS: deployed + diff --git a/cmd/helm/testdata/output/subchart-schema-cli-negative.txt b/cmd/helm/testdata/output/subchart-schema-cli-negative.txt new file mode 100644 index 00000000000..86f6e87a29a --- /dev/null +++ b/cmd/helm/testdata/output/subchart-schema-cli-negative.txt @@ -0,0 +1,4 @@ +Error: values don't meet the specifications of the schema(s) in the following chart(s): +subchart-with-schema: +- age: Must be greater than or equal to 0/1 + diff --git a/cmd/helm/testdata/output/subchart-schema-cli.txt b/cmd/helm/testdata/output/subchart-schema-cli.txt new file mode 100644 index 00000000000..f694bfdf14b --- /dev/null +++ b/cmd/helm/testdata/output/subchart-schema-cli.txt @@ -0,0 +1,5 @@ +NAME: schema +LAST DEPLOYED: 1977-09-02 22:04:05 +0000 UTC +NAMESPACE: default +STATUS: deployed + diff --git a/cmd/helm/testdata/output/subchart-schema-negative.txt b/cmd/helm/testdata/output/subchart-schema-negative.txt new file mode 100644 index 00000000000..5a84170fd16 --- /dev/null +++ b/cmd/helm/testdata/output/subchart-schema-negative.txt @@ -0,0 +1,6 @@ +Error: values don't meet the specifications of the schema(s) in the following chart(s): +chart-without-schema: +- (root): lastname is required +subchart-with-schema: +- (root): age is required + diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml new file mode 100644 index 00000000000..4e24c2ebb6c --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +name: chart-without-schema +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml new file mode 100644 index 00000000000..b5a77c5db76 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +name: subchart-with-schema +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml new file mode 100644 index 00000000000..c80812f6e0a --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json new file mode 100644 index 00000000000..4ff791844bb --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "age" + ] +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml new file mode 100644 index 00000000000..c80812f6e0a --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json new file mode 100644 index 00000000000..f3094803842 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + } + }, + "required": [ + "firstname", + "lastname" + ] +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml new file mode 100644 index 00000000000..c9deafc006a --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml @@ -0,0 +1 @@ +firstname: "John" diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml new file mode 100644 index 00000000000..395d24f6aa0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml new file mode 100644 index 00000000000..c80812f6e0a --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json new file mode 100644 index 00000000000..4df89bbe89f --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml new file mode 100644 index 00000000000..5a1250bff36 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml new file mode 100644 index 00000000000..395d24f6aa0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml new file mode 100644 index 00000000000..76c290c4f4c --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml @@ -0,0 +1,2 @@ +age: -5 +employmentInfo: null diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml new file mode 100644 index 00000000000..c80812f6e0a --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json new file mode 100644 index 00000000000..4df89bbe89f --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml new file mode 100644 index 00000000000..042dea664b8 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml @@ -0,0 +1,17 @@ +firstname: John +lastname: Doe +age: 25 +likesCoffee: true +employmentInfo: + title: Software Developer + salary: 100000 +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/docs/charts.md b/docs/charts.md index 096b8ae6f86..442269e55ed 100644 --- a/docs/charts.md +++ b/docs/charts.md @@ -26,6 +26,7 @@ wordpress/ LICENSE # OPTIONAL: A plain text file containing the license for the chart README.md # OPTIONAL: A human-readable README file values.yaml # The default configuration values for this chart + values.schema.json # OPTIONAL: A JSON Schema for imposing a structure on the values.yaml file charts/ # A directory containing any charts upon which this chart depends. templates/ # A directory of templates that, when combined with values, # will generate valid Kubernetes manifest files. @@ -763,14 +764,98 @@ parent chart. Also, global variables of parent charts take precedence over the global variables from subcharts. +### Schema Files + +Sometimes, a chart maintainer might want to define a structure on their values. +This can be done by defining a schema in the `values.schema.json` file. A +schema is represented as a [JSON Schema](https://json-schema.org/). +It might look something like this: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "image": { + "description": "Container Image", + "properties": { + "repo": { + "type": "string" + }, + "tag": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "description": "Service name", + "type": "string" + }, + "port": { + "description": "Port", + "minimum": 0, + "type": "integer" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "protocol", + "port" + ], + "title": "Values", + "type": "object" +} +``` + +This schema will be applied to the values to validate it. Validation occurs +when any of the following commands are invoked: + +* `helm install` +* `helm upgrade` +* `helm lint` +* `helm template` + +An example of a +`values.yaml` file that meets the requirements of this schema might look +something like this: + +```yaml +name: frontend +protocol: https +port: 443 +``` + +Note that the schema is applied to the final `.Values` object, and not just to +the `values.yaml` file. This means that the following `yaml` file is valid, +given that the chart is installed with the appropriate `--set` option shown +below. + +```yaml +name: frontend +protocol: https +``` + +```` +helm install --set port=443 +```` + +Furthermore, the final `.Values` object is checked against *all* subchart +schemas. This means that restrictions on a subchart can't be circumvented by a +parent chart. This also works backwards - if a subchart has a requirement that +is not met in the subchart's `values.yaml` file, the parent chart *must* +satisfy those restrictions in order to be valid. + ### References -When it comes to writing templates and values files, there are several +When it comes to writing templates, values, and schema files, there are several standard references that will help you out. - [Go templates](https://godoc.org/text/template) - [Extra template functions](https://godoc.org/github.com/Masterminds/sprig) - [The YAML format](http://yaml.org/spec/) +- [JSON Schema](https://json-schema.org/) ## Using Helm to Manage Charts diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go index c442be34405..eec9f9533c9 100644 --- a/pkg/action/lint_test.go +++ b/pkg/action/lint_test.go @@ -29,6 +29,8 @@ var ( invalidArchivedChartPath = "../../cmd/helm/testdata/testcharts/invalidcompressedchart0.1.0.tgz" chartDirPath = "../../cmd/helm/testdata/testcharts/decompressedchart/" chartMissingManifest = "../../cmd/helm/testdata/testcharts/chart-missing-manifest" + chartSchema = "../../cmd/helm/testdata/testcharts/chart-with-schema" + chartSchemaNegative = "../../cmd/helm/testdata/testcharts/chart-with-schema-negative" ) func TestLintChart(t *testing.T) { @@ -47,4 +49,10 @@ func TestLintChart(t *testing.T) { if _, err := lintChart(chartMissingManifest, values, namespace, strict); err == nil { t.Error("Expected a chart parsing error") } + if _, err := lintChart(chartSchema, values, namespace, strict); err != nil { + t.Error(err) + } + if _, err := lintChart(chartSchemaNegative, values, namespace, strict); err != nil { + t.Error(err) + } } diff --git a/pkg/chart/chart.go b/pkg/chart/chart.go index 06aebf37b12..92ad68cb1c6 100644 --- a/pkg/chart/chart.go +++ b/pkg/chart/chart.go @@ -31,6 +31,8 @@ type Chart struct { RawValues []byte // Values are default config for this template. Values map[string]interface{} + // Schema is an optional JSON schema for imposing structure on Values + Schema []byte // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*File diff --git a/pkg/chart/loader/load.go b/pkg/chart/loader/load.go index 67a9f6279ea..cd886d8c70f 100644 --- a/pkg/chart/loader/load.go +++ b/pkg/chart/loader/load.go @@ -90,6 +90,8 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { return c, errors.Wrap(err, "cannot load values.yaml") } c.RawValues = f.Data + case f.Name == "values.schema.json": + c.Schema = f.Data case strings.HasPrefix(f.Name, "templates/"): c.Templates = append(c.Templates, &chart.File{Name: f.Name, Data: f.Data}) case strings.HasPrefix(f.Name, "charts/"): diff --git a/pkg/chart/loader/load_test.go b/pkg/chart/loader/load_test.go index b5159d04f8f..c5ee1e5a1c1 100644 --- a/pkg/chart/loader/load_test.go +++ b/pkg/chart/loader/load_test.go @@ -17,6 +17,7 @@ limitations under the License. package loader import ( + "bytes" "testing" "helm.sh/helm/pkg/chart" @@ -78,6 +79,10 @@ icon: https://example.com/64x64.png Name: "values.yaml", Data: []byte("var: some values"), }, + { + Name: "values.schema.json", + Data: []byte("type: Values"), + }, { Name: "templates/deployment.yaml", Data: []byte("some deployment"), @@ -101,6 +106,10 @@ icon: https://example.com/64x64.png t.Error("Expected chart values to be populated with default values") } + if !bytes.Equal(c.Schema, []byte("type: Values")) { + t.Error("Expected chart schema to be populated with default values") + } + if len(c.Templates) != 2 { t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) } diff --git a/pkg/chartutil/testdata/test-values-negative.yaml b/pkg/chartutil/testdata/test-values-negative.yaml new file mode 100644 index 00000000000..5a1250bff36 --- /dev/null +++ b/pkg/chartutil/testdata/test-values-negative.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/chartutil/testdata/test-values.schema.json b/pkg/chartutil/testdata/test-values.schema.json new file mode 100644 index 00000000000..4df89bbe89f --- /dev/null +++ b/pkg/chartutil/testdata/test-values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/pkg/chartutil/testdata/test-values.yaml b/pkg/chartutil/testdata/test-values.yaml new file mode 100644 index 00000000000..042dea664b8 --- /dev/null +++ b/pkg/chartutil/testdata/test-values.yaml @@ -0,0 +1,17 @@ +firstname: John +lastname: Doe +age: 25 +likesCoffee: true +employmentInfo: + title: Software Developer + salary: 100000 +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/chartutil/values.go b/pkg/chartutil/values.go index 644cdd49be9..7edc7523341 100644 --- a/pkg/chartutil/values.go +++ b/pkg/chartutil/values.go @@ -17,6 +17,7 @@ limitations under the License. package chartutil import ( + "bytes" "fmt" "io" "io/ioutil" @@ -25,6 +26,7 @@ import ( "github.com/ghodss/yaml" "github.com/pkg/errors" + "github.com/xeipuuv/gojsonschema" "helm.sh/helm/pkg/chart" ) @@ -133,6 +135,64 @@ func ReadValuesFile(filename string) (Values, error) { return ReadValues(data) } +// ValidateAgainstSchema checks that values does not violate the structure laid out in schema +func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error { + var sb strings.Builder + if chrt.Schema != nil { + err := ValidateAgainstSingleSchema(values, chrt.Schema) + if err != nil { + sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name())) + sb.WriteString(err.Error()) + } + } + + // For each dependency, recurively call this function with the coalesced values + for _, subchrt := range chrt.Dependencies() { + subchrtValues := values[subchrt.Name()].(map[string]interface{}) + if err := ValidateAgainstSchema(subchrt, subchrtValues); err != nil { + sb.WriteString(err.Error()) + } + } + + if sb.Len() > 0 { + return errors.New(sb.String()) + } + + return nil +} + +// ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema +func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) error { + valuesData, err := yaml.Marshal(values) + if err != nil { + return err + } + valuesJSON, err := yaml.YAMLToJSON(valuesData) + if err != nil { + return err + } + if bytes.Equal(valuesJSON, []byte("null")) { + valuesJSON = []byte("{}") + } + schemaLoader := gojsonschema.NewBytesLoader(schemaJSON) + valuesLoader := gojsonschema.NewBytesLoader(valuesJSON) + + result, err := gojsonschema.Validate(schemaLoader, valuesLoader) + if err != nil { + return err + } + + if !result.Valid() { + var sb strings.Builder + for _, desc := range result.Errors() { + sb.WriteString(fmt.Sprintf("- %s\n", desc)) + } + return errors.New(sb.String()) + } + + return nil +} + // CoalesceValues coalesces all of the values in a chart (and its subcharts). // // Values are coalesced together using the following rules: @@ -331,6 +391,11 @@ func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options return top, err } + if err := ValidateAgainstSchema(chrt, vals); err != nil { + errFmt := "values don't meet the specifications of the schema(s) in the following chart(s):\n%s" + return top, fmt.Errorf(errFmt, err.Error()) + } + top["Values"] = vals return top, nil } diff --git a/pkg/chartutil/values_test.go b/pkg/chartutil/values_test.go index 2faaf782809..43aa721893d 100644 --- a/pkg/chartutil/values_test.go +++ b/pkg/chartutil/values_test.go @@ -20,6 +20,7 @@ import ( "bytes" "encoding/json" "fmt" + "io/ioutil" "testing" "text/template" @@ -160,6 +161,125 @@ func TestReadValuesFile(t *testing.T) { matchValues(t, data) } +func TestValidateAgainstSingleSchema(t *testing.T) { + values, err := ReadValuesFile("./testdata/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := ioutil.ReadFile("./testdata/test-values.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + if err := ValidateAgainstSingleSchema(values, schema); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstSingleSchemaNegative(t *testing.T) { + values, err := ReadValuesFile("./testdata/test-values-negative.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := ioutil.ReadFile("./testdata/test-values.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + var errString string + if err := ValidateAgainstSingleSchema(values, schema); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `- (root): employmentInfo is required +- age: Must be greater than or equal to 0/1 +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +const subchrtSchema = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "age" + ] +} +` + +func TestValidateAgainstSchema(t *testing.T) { + subchrtJSON := []byte(subchrtSchema) + subchrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchrt", + }, + Schema: subchrtJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchrt) + + vals := map[string]interface{}{ + "name": "John", + "subchrt": map[string]interface{}{ + "age": 25, + }, + } + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstSchemaNegative(t *testing.T) { + subchrtJSON := []byte(subchrtSchema) + subchrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchrt", + }, + Schema: subchrtJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchrt) + + vals := map[string]interface{}{ + "name": "John", + "subchrt": map[string]interface{}{}, + } + + var errString string + if err := ValidateAgainstSchema(chrt, vals); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `subchrt: +- (root): age is required +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + func ExampleValues() { doc := ` title: "Moby Dick" @@ -399,6 +519,7 @@ func TestCoalesceTables(t *testing.T) { t.Errorf("Expected boat string, got %v", dst["boat"]) } } + func TestPathValue(t *testing.T) { doc := ` title: "Moby Dick" diff --git a/pkg/lint/rules/values.go b/pkg/lint/rules/values.go index f3d9a7317cb..a0310e93474 100644 --- a/pkg/lint/rules/values.go +++ b/pkg/lint/rules/values.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "io/ioutil" "os" "path/filepath" @@ -48,6 +49,19 @@ func validateValuesFileExistence(valuesPath string) error { } func validateValuesFile(valuesPath string) error { - _, err := chartutil.ReadValuesFile(valuesPath) - return errors.Wrap(err, "unable to parse YAML") + values, err := chartutil.ReadValuesFile(valuesPath) + if err != nil { + return errors.Wrap(err, "unable to parse YAML") + } + + ext := filepath.Ext(valuesPath) + schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" + schema, err := ioutil.ReadFile(schemaPath) + if len(schema) == 0 { + return nil + } + if err != nil { + return err + } + return chartutil.ValidateAgainstSingleSchema(values, schema) }