Feat/schema validation ()

* Add the Schema type and a function to read it

* Added a function to read a schema from a file

* Check that values.yaml matches schema

This commit uses the gojsonschema package to validate a values.yaml file
against a corresponding values.schema.yaml file.

* Add functionality to generate a schema from a values.yaml

* Add Schema to Chart and loader

* Clean up implementation in chartutil

* Add tests for helm install with schema

* Add schema validation to helm lint

* Clean up "matchSchema"

* Modify error output

* Add documentation

* Fix a linter issue

* Fix a test that broke during a rebase

* Clean up documentation

* Specify JSONSchema spec

Since JSONSchema is still in a draft state as of this commit, we need to
specify a particular version of the JSONSchema spec

* Switch to using builtin functionality for file extensions

* Switch to using a third-party library for JSON conversion

* Use the constants from the gojsonschema package

* Updates to unit tests

* Minor change to avoid string cast

* Remove JSON Schema generation

* Change Schema type from map[string]interface{} to []byte

* Convert all Schema YAML to JSON

* Fix some tests that were broken by a rebase

* Fix up YAML/JSON conversions

* This checks subcharts for schema validation

The final coalesced values for a given chart will be validated against
that chart's schema, as well as any dependent subchart's schema

* Add unit tests for ValidateAgainstSchema

* Remove nonessential test files

* Remove a misleading unit test

The TestReadSchema unit test was simply testing the ReadValues function,
which is already being validated in the TestReadValues unit test

* Update documentation to reflect changes to subchart schemas
pull/5649/head
Ian Howell 6 years ago committed by Adam Reese
parent 3dd1765491
commit ffff0e8c33

25
Gopkg.lock generated

@ -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",

@ -107,3 +107,6 @@
go-tests = true
unused-packages = true
[[constraint]]
name = "github.com/xeipuuv/gojsonschema"
version = "1.1.0"

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

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

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

@ -0,0 +1,5 @@
NAME: schema
LAST DEPLOYED: 1977-09-02 22:04:05 +0000 UTC
NAMESPACE: default
STATUS: deployed

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

@ -0,0 +1,5 @@
NAME: schema
LAST DEPLOYED: 1977-09-02 22:04:05 +0000 UTC
NAMESPACE: default
STATUS: deployed

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

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

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

@ -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"
]
}

@ -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"
]
}

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

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

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

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

@ -0,0 +1,2 @@
age: -5
employmentInfo: null

@ -0,0 +1 @@
# This file is intentionally blank

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

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

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

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

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

@ -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/"):

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

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

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

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

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

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

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

Loading…
Cancel
Save