diff options
Diffstat (limited to 'config')
-rw-r--r-- | config/common.go | 12 | ||||
-rw-r--r-- | config/policy.go | 141 | ||||
-rw-r--r-- | config/policy_test.go | 117 | ||||
-rw-r--r-- | config/validation.go | 31 |
4 files changed, 291 insertions, 10 deletions
diff --git a/config/common.go b/config/common.go index a597a82..4cc300d 100644 --- a/config/common.go +++ b/config/common.go @@ -9,12 +9,12 @@ import ( // type CommonConfig struct { Base string `yaml:"base" validate:"omitempty,baseimage"` // name/path to base image - Apt AptConfig `yaml:"apt"` // APT related configuration - Node NodeConfig `yaml:"node"` // Node related configuration - Python PythonConfig `yaml:"python"` // Python related configuration - Lives LivesConfig `yaml:"lives"` // application owner/dir configuration - Runs RunsConfig `yaml:"runs"` // runtime environment configuration - SharedVolume Flag `yaml:"sharedvolume"` // define a volume for application + Apt AptConfig `yaml:"apt"` // APT related + Node NodeConfig `yaml:"node"` // Node related + Python PythonConfig `yaml:"python"` // Python related + Lives LivesConfig `yaml:"lives"` // application owner/dir + Runs RunsConfig `yaml:"runs"` // runtime environment + SharedVolume Flag `yaml:"sharedvolume"` // use volume for app EntryPoint []string `yaml:"entrypoint"` // entry-point executable } diff --git a/config/policy.go b/config/policy.go new file mode 100644 index 0000000..25fe02a --- /dev/null +++ b/config/policy.go @@ -0,0 +1,141 @@ +package config + +import ( + "errors" + "fmt" + "io/ioutil" + "reflect" + "strings" + + "github.com/utahta/go-openuri" + "gopkg.in/yaml.v2" +) + +// Policy validates a number of rules against a given configuration. +// +type Policy struct { + Enforcements []Enforcement `yaml:"enforcements"` +} + +// Validate checks the given config against all policy enforcements. +// +func (pol Policy) Validate(config Config) error { + validate := NewValidator() + + for _, enforcement := range pol.Enforcements { + cfg, err := ResolveYAMLPath(enforcement.Path, config) + + if err != nil { + // If the path resolved nothing, there's nothing to enforce + continue + } + + // Flags are a special case in which the True field should be compared + // against the validator, not the struct itself. + if flag, ok := cfg.(Flag); ok { + cfg = flag.True + } + + err = validate.Var(cfg, enforcement.Rule) + + if err != nil { + return fmt.Errorf( + `value for "%s" violates policy rule "%s"`, + enforcement.Path, enforcement.Rule, + ) + } + } + + return nil +} + +// Enforcement represents a policy rule and config path on which to apply it. +// +type Enforcement struct { + Path string `yaml:"path"` + Rule string `yaml:"rule"` +} + +// ReadPolicy unmarshals the given YAML bytes into a new Policy struct. +// +func ReadPolicy(data []byte) (*Policy, error) { + var policy Policy + + err := yaml.Unmarshal(data, &policy) + + if err != nil { + return nil, err + } + + return &policy, err +} + +// ReadPolicyFromURI fetches the policy file from the given URL or file path +// and loads its contents with ReadPolicy. +// +func ReadPolicyFromURI(uri string) (*Policy, error) { + io, err := openuri.Open(uri) + + if err != nil { + return nil, err + } + + defer io.Close() + + data, err := ioutil.ReadAll(io) + + if err != nil { + return nil, err + } + + return ReadPolicy(data) +} + +// ResolveYAMLPath returns the config value found at the given YAML-ish +// namespace/path (e.g. "variants.production.runs.as"). +// +func ResolveYAMLPath(path string, cfg interface{}) (interface{}, error) { + parts := strings.SplitN(path, ".", 2) + name := parts[0] + + v := reflect.ValueOf(cfg) + t := v.Type() + + var subcfg interface{} + + switch t.Kind() { + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Anonymous { + if subsubcfg, err := ResolveYAMLPath(path, v.Field(i).Interface()); err == nil { + return subsubcfg, nil + } + } + + if name == resolveYAMLTagName(t.Field(i)) { + subcfg = v.Field(i).Interface() + break + } + } + + case reflect.Map: + if t.Key().Kind() == reflect.String { + for _, key := range v.MapKeys() { + if key.Interface().(string) == name { + subcfg = v.MapIndex(key).Interface() + break + } + } + } + } + + if subcfg == nil { + return nil, errors.New("invalid path") + } + + if len(parts) > 1 { + return ResolveYAMLPath(parts[1], subcfg) + } + + return subcfg, nil +} diff --git a/config/policy_test.go b/config/policy_test.go new file mode 100644 index 0000000..2d7a3a7 --- /dev/null +++ b/config/policy_test.go @@ -0,0 +1,117 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "phabricator.wikimedia.org/source/blubber/config" +) + +func TestPolicyRead(t *testing.T) { + policy, err := config.ReadPolicy([]byte(`--- + enforcements: + - path: variants.production.runs.as + rule: ne=root + - path: base + rule: oneof=debian:jessie debian:stretch`)) + + if assert.NoError(t, err) { + if assert.Len(t, policy.Enforcements, 2) { + assert.Equal(t, "variants.production.runs.as", policy.Enforcements[0].Path) + assert.Equal(t, "ne=root", policy.Enforcements[0].Rule) + + assert.Equal(t, "base", policy.Enforcements[1].Path) + assert.Equal(t, "oneof=debian:jessie debian:stretch", policy.Enforcements[1].Rule) + } + } +} + +func TestPolicyValidate(t *testing.T) { + cfg := config.Config{ + CommonConfig: config.CommonConfig{ + Base: "foo:tag", + }, + Variants: map[string]config.VariantConfig{ + "foo": config.VariantConfig{ + CommonConfig: config.CommonConfig{ + Runs: config.RunsConfig{ + UserConfig: config.UserConfig{ + As: "root", + }, + }, + }, + }, + }, + } + + policy := config.Policy{ + Enforcements: []config.Enforcement{ + {Path: "variants.foo.runs.as", Rule: "ne=root"}, + }, + } + + assert.EqualError(t, + policy.Validate(cfg), + `value for "variants.foo.runs.as" violates policy rule "ne=root"`, + ) + + policy = config.Policy{ + Enforcements: []config.Enforcement{ + {Path: "base", Rule: "oneof=debian:jessie debian:stretch"}, + }, + } + + assert.EqualError(t, + policy.Validate(cfg), + `value for "base" violates policy rule "oneof=debian:jessie debian:stretch"`, + ) +} + +func TestEnforcementOnFlag(t *testing.T) { + cfg := config.Config{ + Variants: map[string]config.VariantConfig{ + "production": config.VariantConfig{ + CommonConfig: config.CommonConfig{ + Node: config.NodeConfig{ + Dependencies: config.Flag{True: true}, + }, + }, + }, + }, + } + + policy := config.Policy{ + Enforcements: []config.Enforcement{ + {Path: "variants.production.node.dependencies", Rule: "isfalse"}, + }, + } + + assert.Error(t, + policy.Validate(cfg), + `value for "variants.production.node.dependencies" violates policy rule "isfalse"`, + ) + +} + +func TestResolveYAMLPath(t *testing.T) { + cfg := config.Config{ + Variants: map[string]config.VariantConfig{ + "foo": config.VariantConfig{ + CommonConfig: config.CommonConfig{ + Runs: config.RunsConfig{ + UserConfig: config.UserConfig{ + As: "root", + }, + }, + }, + }, + }, + } + + val, err := config.ResolveYAMLPath("variants.foo.runs.as", cfg) + + if assert.NoError(t, err) { + assert.Equal(t, "root", val) + } +} diff --git a/config/validation.go b/config/validation.go index 652a3c8..3359211 100644 --- a/config/validation.go +++ b/config/validation.go @@ -52,6 +52,8 @@ var ( "baseimage": isBaseImage, "debianpackage": isDebianPackage, "envvars": isEnvironmentVariables, + "isfalse": isFalse, + "istrue": isTrue, "variantref": isVariantReference, "variants": hasVariantNames, } @@ -61,11 +63,10 @@ type ctxKey uint8 const rootCfgCtx ctxKey = iota -// Validate runs all validations defined for config fields against the given -// Config value. If the returned error is not nil, it will contain a -// user-friendly message describing all invalid field values. +// NewValidator returns a validator instance for which our custom aliases and +// functions are registered. // -func Validate(config Config) error { +func NewValidator() *validator.Validate { validate := validator.New() validate.RegisterTagNameFunc(resolveYAMLTagName) @@ -78,6 +79,16 @@ func Validate(config Config) error { validate.RegisterValidationCtx(name, f) } + return validate +} + +// Validate runs all validations defined for config fields against the given +// Config value. If the returned error is not nil, it will contain a +// user-friendly message describing all invalid field values. +// +func Validate(config Config) error { + validate := NewValidator() + ctx := context.WithValue(context.Background(), rootCfgCtx, config) return validate.StructCtx(ctx, config) @@ -169,6 +180,18 @@ func isEnvironmentVariables(_ context.Context, fl validator.FieldLevel) bool { return true } +func isFalse(_ context.Context, fl validator.FieldLevel) bool { + val, ok := fl.Field().Interface().(bool) + + return ok && val == false +} + +func isTrue(_ context.Context, fl validator.FieldLevel) bool { + val, ok := fl.Field().Interface().(bool) + + return ok && val == true +} + func isVariantReference(ctx context.Context, fl validator.FieldLevel) bool { cfg := ctx.Value(rootCfgCtx).(Config) ref := fl.Field().String() |