cmd/config run scoping and path defaulting

- default the path and index for Resources generated by functions
- scope functions to only operate against resources in subdirectories
This commit is contained in:
Phillip Wittrock
2020-01-08 22:11:57 -08:00
parent 9fe9a2500a
commit 2f5be62387
7 changed files with 994 additions and 50 deletions

View File

@@ -8,8 +8,10 @@ import (
"fmt"
"os"
"os/exec"
"path"
"strings"
"sigs.k8s.io/kustomize/kyaml/errors"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
@@ -24,6 +26,106 @@ import (
// non-zero.
// The full set of environment variables from the parent process
// are passed to the container.
//
// Function Scoping:
// ContainerFilter applies the function only to Resources to which it is scoped.
//
// Resources are scoped to a function if any of the following are true:
// - the Resource were read from the same directory as the function config
// - the Resource were read from a subdirectory of the function config directory
// - the function config is in a directory named "functions" and
// they were read from a subdirectory of "functions" parent
// - the function config doesn't have a path annotation (considered globally scoped)
// - the ContainerFilter has GlobalScope == true
//
// In Scope Examples:
//
// Example 1: deployment.yaml and service.yaml in function.yaml scope
// same directory as the function config directory
// .
// ├── function.yaml
// ├── deployment.yaml
// └── service.yaml
//
// Example 2: apps/deployment.yaml and apps/service.yaml in function.yaml scope
// subdirectory of the function config directory
// .
// ├── function.yaml
// └── apps
//    ├── deployment.yaml
//    └── service.yaml
//
// Example 3: apps/deployment.yaml and apps/service.yaml in functions/function.yaml scope
// function config is in a directory named "functions"
// .
// ├── functions
// │   └── function.yaml
// └── apps
//    ├── deployment.yaml
//    └── service.yaml
//
// Out of Scope Examples:
//
// Example 1: apps/deployment.yaml and apps/service.yaml NOT in stuff/function.yaml scope
// .
// ├── stuff
// │   └── function.yaml
// └── apps
//    ├── deployment.yaml
//    └── service.yaml
//
// Example 2: apps/deployment.yaml and apps/service.yaml NOT in stuff/functions/function.yaml scope
// .
// ├── stuff
// │   └── functions
// │    └── function.yaml
// └── apps
//    ├── deployment.yaml
//    └── service.yaml
//
// Default Paths:
// Resources emitted by functions will have default path applied as annotations
// if none is present.
// The default path will be the function-dir/ (or parent directory in the case of "functions")
// + function-file-name/ + namespace/ + kind_name.yaml
//
// Example 1: Given a function in fn.yaml that produces a Deployment name foo and a Service named bar
// dir
// └── fn.yaml
//
// Would default newly generated Resources to:
//
// dir
// ├── fn.yaml
// └── fn
//    ├── deployment_foo.yaml
//    └── service_bar.yaml
//
// Example 2: Given a function in functions/fn.yaml that produces a Deployment name foo and a Service named bar
// dir
// └── fn.yaml
//
// Would default newly generated Resources to:
//
// dir
// ├── functions
// │   └── fn.yaml
// └── fn
//    ├── deployment_foo.yaml
//    └── service_bar.yaml
//
// Example 3: Given a function in fn.yaml that produces a Deployment name foo, namespace baz and a Service named bar namespace baz
// dir
// └── fn.yaml
//
// Would default newly generated Resources to:
//
// dir
// ├── fn.yaml
// └── fn
// └── baz
//    ├── deployment_foo.yaml
//    └── service_bar.yaml
type ContainerFilter struct {
// Image is the container image to use to create a container.
@@ -40,6 +142,10 @@ type ContainerFilter struct {
// Typically a Kubernetes style Resource Config.
Config *yaml.RNode `yaml:"config,omitempty"`
// GlobalScope will cause the function to be run against all input
// nodes instead of only nodes scoped under the function.
GlobalScope bool
// args may be specified by tests to override how a container is spawned
args []string
@@ -65,8 +171,76 @@ func (s *StorageMount) String() string {
return fmt.Sprintf("type=%s,src=%s,dst=%s:ro", s.MountType, s.Src, s.DstPath)
}
// functionsDirectoryName is keyword directory name for functions scoped 1 directory higher
const functionsDirectoryName = "functions"
// getFunctionScope returns the path of the directory containing the function config,
// or its parent directory if the base directory is named "functions"
func (c *ContainerFilter) getFunctionScope() (string, error) {
m, err := c.Config.GetMeta()
if err != nil {
return "", errors.Wrap(err)
}
p, found := m.Annotations[kioutil.PathAnnotation]
if !found {
return "", nil
}
functionDir := path.Clean(path.Dir(p))
if path.Base(functionDir) == functionsDirectoryName {
// the scope of functions in a directory called "functions" is 1 level higher
// this is similar to how the golang "internal" directory scoping works
functionDir = path.Dir(functionDir)
}
return functionDir, nil
}
// scope partitions the input nodes into 2 slices. The first slice contains only Resources
// which are scoped under dir, and the second slice contains the Resources which are not.
func (c *ContainerFilter) scope(dir string, nodes []*yaml.RNode) ([]*yaml.RNode, []*yaml.RNode, error) {
// scope container filtered Resources to Resources under that directory
var input, saved []*yaml.RNode
if c.GlobalScope {
return nodes, nil, nil
}
if dir == "" {
// global function
return nodes, nil, nil
}
// identify Resources read from directories under the function configuration
for i := range nodes {
m, err := nodes[i].GetMeta()
if err != nil {
return nil, nil, err
}
p, found := m.Annotations[kioutil.PathAnnotation]
if !found {
// this Resource isn't scoped under the function -- don't know where it came from
// consider it out of scope
saved = append(saved, nodes[i])
continue
}
resourceDir := path.Clean(path.Dir(p))
if !strings.HasPrefix(resourceDir, dir) {
// this Resource doesn't fall under the function scope if it
// isn't in a subdirectory of where the function lives
saved = append(saved, nodes[i])
continue
}
// this input is scoped under the function
input = append(input, nodes[i])
}
return input, saved, nil
}
// GrepFilter implements kio.GrepFilter
func (c *ContainerFilter) Filter(input []*yaml.RNode) ([]*yaml.RNode, error) {
func (c *ContainerFilter) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
// get the command to filter the Resources
cmd, err := c.getCommand()
if err != nil {
@@ -76,11 +250,23 @@ func (c *ContainerFilter) Filter(input []*yaml.RNode) ([]*yaml.RNode, error) {
in := &bytes.Buffer{}
out := &bytes.Buffer{}
// only process Resources scoped to this function, save the others
functionDir, err := c.getFunctionScope()
if err != nil {
return nil, err
}
input, saved, err := c.scope(functionDir, nodes)
if err != nil {
return nil, err
}
// write the input
err = kio.ByteWriter{
WrappingAPIVersion: kio.ResourceListAPIVersion,
WrappingKind: kio.ResourceListKind,
Writer: in, KeepReaderAnnotations: true, FunctionConfig: c.Config}.Write(input)
WrappingAPIVersion: kio.ResourceListAPIVersion,
WrappingKind: kio.ResourceListKind,
Writer: in,
KeepReaderAnnotations: true,
FunctionConfig: c.Config}.Write(input)
if err != nil {
return nil, err
}
@@ -98,7 +284,19 @@ func (c *ContainerFilter) Filter(input []*yaml.RNode) ([]*yaml.RNode, error) {
return nil, err
}
return r.Read()
output, err := r.Read()
if err != nil {
return nil, err
}
// annotate any generated Resources with a path and index if they don't already have one
if err := kioutil.DefaultPathAnnotation(functionDir, output); err != nil {
return nil, err
}
// emit both the Resources output from the function, and the out-of-scope Resources
// which were not provided to the function
return append(output, saved...), nil
}
// getArgs returns the command + args to run to spawn the container
@@ -139,7 +337,7 @@ func (c *ContainerFilter) getArgs() []string {
return append(args, c.Image)
}
// getCommand returns a command which will apply the GrepFilter using the container image
// getCommand returns a command which will apply the Filter using the container image
func (c *ContainerFilter) getCommand() (*exec.Cmd, error) {
// encode the filter command API configuration
cfg := &bytes.Buffer{}

View File

@@ -16,7 +16,7 @@ import (
)
func TestFilter_command(t *testing.T) {
cfg, err := yaml.Parse(`apiversion: apps/v1
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
@@ -62,7 +62,7 @@ metadata:
}
func TestFilter_command_StorageMount(t *testing.T) {
cfg, err := yaml.Parse(`apiversion: apps/v1
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
@@ -103,7 +103,7 @@ metadata:
}
func TestFilter_command_network(t *testing.T) {
cfg, err := yaml.Parse(`apiversion: apps/v1
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
@@ -213,6 +213,7 @@ metadata:
name: deployment-foo
annotations:
config.kubernetes.io/index: '0'
config.kubernetes.io/path: 'statefulset_deployment-foo.yaml'
---
apiVersion: v1
kind: Service
@@ -220,11 +221,12 @@ metadata:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
config.kubernetes.io/path: 'service_service-foo.yaml'
`, b.String())
}
func TestFilter_Filter_noChange(t *testing.T) {
cfg, err := yaml.Parse(`apiversion: apps/v1
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
@@ -234,7 +236,7 @@ metadata:
}
input, err := (&kio.ByteReader{Reader: bytes.NewBufferString(`
apiversion: apps/v1
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
@@ -258,7 +260,7 @@ metadata:
if !assert.Equal(t, `apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items:
- apiversion: apps/v1
- apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
@@ -270,7 +272,7 @@ items:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
functionConfig: {apiversion: apps/v1, kind: Deployment, metadata: {name: foo}}
functionConfig: {apiVersion: apps/v1, kind: Deployment, metadata: {name: foo}}
`, s) {
t.FailNow()
}
@@ -289,12 +291,13 @@ functionConfig: {apiversion: apps/v1, kind: Deployment, metadata: {name: foo}}
return
}
assert.Equal(t, `apiversion: apps/v1
assert.Equal(t, `apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/index: '0'
config.kubernetes.io/path: 'deployment_deployment-foo.yaml'
---
apiVersion: v1
kind: Service
@@ -302,6 +305,7 @@ metadata:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
config.kubernetes.io/path: 'service_service-foo.yaml'
`, b.String())
}
@@ -359,3 +363,418 @@ metadata:
c, _ = GetContainerName(n)
assert.Equal(t, "", c)
}
func TestFilter_Filter_defaultNaming(t *testing.T) {
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
annotations:
config.kubernetes.io/path: 'foo/bar.yaml'
`)
if !assert.NoError(t, err) {
return
}
input, err := (&kio.ByteReader{Reader: bytes.NewBufferString(``)}).Read()
if !assert.NoError(t, err) {
return
}
called := false
result, err := (&ContainerFilter{
Image: "example.com:version",
Config: cfg,
args: []string{"echo", `apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
`},
checkInput: func(s string) {
called = true
if !assert.Equal(t, `apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items: []
functionConfig: {apiVersion: apps/v1, kind: Deployment, metadata: {name: foo, annotations: {
config.kubernetes.io/path: 'foo/bar.yaml'}}}
`, s) {
t.FailNow()
}
},
}).Filter(input)
if !assert.NoError(t, err) {
return
}
if !assert.True(t, called) {
return
}
b := &bytes.Buffer{}
err = kio.ByteWriter{Writer: b, KeepReaderAnnotations: true}.Write(result)
if !assert.NoError(t, err) {
return
}
assert.Equal(t, `apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/index: '0'
config.kubernetes.io/path: 'foo/deployment_deployment-foo.yaml'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
config.kubernetes.io/path: 'foo/service_service-foo.yaml'
`, b.String())
}
func TestFilter_Filter_defaultNamingFunctions(t *testing.T) {
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
annotations:
config.kubernetes.io/path: 'foo/functions/bar.yaml'
`)
if !assert.NoError(t, err) {
return
}
input, err := (&kio.ByteReader{Reader: bytes.NewBufferString(``)}).Read()
if !assert.NoError(t, err) {
return
}
called := false
result, err := (&ContainerFilter{
Image: "example.com:version",
Config: cfg,
args: []string{"echo", `apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
`},
checkInput: func(s string) {
called = true
if !assert.Equal(t, `apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items: []
functionConfig: {apiVersion: apps/v1, kind: Deployment, metadata: {name: foo, annotations: {
config.kubernetes.io/path: 'foo/functions/bar.yaml'}}}
`, s) {
t.FailNow()
}
},
}).Filter(input)
if !assert.NoError(t, err) {
return
}
if !assert.True(t, called) {
return
}
b := &bytes.Buffer{}
err = kio.ByteWriter{Writer: b, KeepReaderAnnotations: true}.Write(result)
if !assert.NoError(t, err) {
return
}
assert.Equal(t, `apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/index: '0'
config.kubernetes.io/path: 'foo/deployment_deployment-foo.yaml'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
config.kubernetes.io/path: 'foo/service_service-foo.yaml'
`, b.String())
}
func TestFilter_Filter_scopeMissingFromResource(t *testing.T) {
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
annotations:
config.kubernetes.io/path: 'foo/bar.yaml'
`)
if !assert.NoError(t, err) {
return
}
input, err := (&kio.ByteReader{Reader: bytes.NewBufferString(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
`)}).Read()
if !assert.NoError(t, err) {
return
}
// no resources match the scope
called := false
result, err := (&ContainerFilter{
Image: "example.com:version",
Config: cfg,
args: []string{"sed", "s/Deployment/StatefulSet/g"},
checkInput: func(s string) {
called = true
if !assert.Equal(t, `apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items: []
functionConfig: {apiVersion: apps/v1, kind: Deployment, metadata: {name: foo, annotations: {
config.kubernetes.io/path: 'foo/bar.yaml'}}}
`, s) {
t.FailNow()
}
},
}).Filter(input)
if !assert.NoError(t, err) {
return
}
if !assert.True(t, called) {
return
}
b := &bytes.Buffer{}
err = kio.ByteWriter{Writer: b, KeepReaderAnnotations: true}.Write(result)
if !assert.NoError(t, err) {
return
}
// Resources should be preserved -- paths shouldn't be set by container
assert.Equal(t, `apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/index: '0'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
`, b.String())
}
func TestFilter_Filter_scopeFunctionsDir(t *testing.T) {
// functions under "functions/" dir should be scoped to parent dir
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
annotations:
config.kubernetes.io/path: 'foo/functions/bar.yaml'
`)
if !assert.NoError(t, err) {
return
}
input, err := (&kio.ByteReader{Reader: bytes.NewBufferString(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/path: 'foo/bar/d.yaml'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/path: 'foo/bar/s.yaml'
`)}).Read()
if !assert.NoError(t, err) {
return
}
// no resources match the scope
called := false
result, err := (&ContainerFilter{
Image: "example.com:version",
Config: cfg,
args: []string{"sed", "s/Deployment/StatefulSet/g"},
checkInput: func(s string) {
called = true
if !assert.Equal(t, `apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items:
- apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/path: 'foo/bar/d.yaml'
config.kubernetes.io/index: '0'
- apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/path: 'foo/bar/s.yaml'
config.kubernetes.io/index: '1'
functionConfig: {apiVersion: apps/v1, kind: Deployment, metadata: {name: foo, annotations: {
config.kubernetes.io/path: 'foo/functions/bar.yaml'}}}
`, s) {
t.FailNow()
}
},
}).Filter(input)
if !assert.NoError(t, err) {
return
}
if !assert.True(t, called) {
return
}
b := &bytes.Buffer{}
err = kio.ByteWriter{Writer: b, KeepReaderAnnotations: true}.Write(result)
if !assert.NoError(t, err) {
return
}
// Resources should be modified
assert.Equal(t, `apiVersion: apps/v1
kind: StatefulSet
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/path: 'foo/bar/d.yaml'
config.kubernetes.io/index: '0'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/path: 'foo/bar/s.yaml'
config.kubernetes.io/index: '1'
`, b.String())
}
func TestFilter_Filter_scopeDir(t *testing.T) {
// functions under "functions/" dir should be scoped to parent dir
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
annotations:
config.kubernetes.io/path: 'foo/bar.yaml'
`)
if !assert.NoError(t, err) {
return
}
input, err := (&kio.ByteReader{Reader: bytes.NewBufferString(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/path: 'foo/bar/d.yaml'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/path: 'foo/bar/s.yaml'
`)}).Read()
if !assert.NoError(t, err) {
return
}
// no resources match the scope
called := false
result, err := (&ContainerFilter{
Image: "example.com:version",
Config: cfg,
args: []string{"sed", "s/Deployment/StatefulSet/g"},
checkInput: func(s string) {
called = true
if !assert.Equal(t, `apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items:
- apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/path: 'foo/bar/d.yaml'
config.kubernetes.io/index: '0'
- apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/path: 'foo/bar/s.yaml'
config.kubernetes.io/index: '1'
functionConfig: {apiVersion: apps/v1, kind: Deployment, metadata: {name: foo, annotations: {
config.kubernetes.io/path: 'foo/bar.yaml'}}}
`, s) {
t.FailNow()
}
},
}).Filter(input)
if !assert.NoError(t, err) {
return
}
if !assert.True(t, called) {
return
}
b := &bytes.Buffer{}
err = kio.ByteWriter{Writer: b, KeepReaderAnnotations: true}.Write(result)
if !assert.NoError(t, err) {
return
}
// Resources should be preserved
assert.Equal(t, `apiVersion: apps/v1
kind: StatefulSet
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/path: 'foo/bar/d.yaml'
config.kubernetes.io/index: '0'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/path: 'foo/bar/s.yaml'
config.kubernetes.io/index: '1'
`, b.String())
}