Refactor container function runtime into its own package

This commit is contained in:
Phillip Wittrock
2020-05-04 08:11:04 -07:00
parent 096ad8c95c
commit 594c48d19a
11 changed files with 773 additions and 2161 deletions

View File

@@ -0,0 +1,202 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package container
import (
"fmt"
"os"
"strings"
runtimeexec "sigs.k8s.io/kustomize/kyaml/fn/runtime/exec"
"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
// Filter filters Resources using a container image.
// The container must start a process that reads the list of
// input Resources from stdin, reads the Configuration from the env
// API_CONFIG, and writes the filtered Resources to stdout.
// If there is a error or validation failure, the process must exit
// non-zero.
// The full set of environment variables from the parent process
// are passed to the container.
//
// Function Scoping:
// Filter 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 Filter 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 Filter struct {
// Image is the container image to use to create a container.
Image string `yaml:"image,omitempty"`
// Network is the container network to use.
Network string `yaml:"network,omitempty"`
// StorageMounts is a list of storage options that the container will have mounted.
StorageMounts []runtimeutil.StorageMount `yaml:"mounts,omitempty"`
Exec runtimeexec.Filter
}
func (c Filter) String() string {
if c.Exec.DeferFailure {
return fmt.Sprintf("%s deferFailure: %v", c.Image, c.Exec.DeferFailure)
}
return c.Image
}
func (c Filter) GetExit() error {
return c.Exec.GetExit()
}
func (c *Filter) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
c.setupExec()
return c.Exec.Filter(nodes)
}
func (c *Filter) setupExec() {
// don't init 2x
if c.Exec.Path != "" {
return
}
path, args := c.getCommand()
c.Exec.Path = path
c.Exec.Args = args
}
// getArgs returns the command + args to run to spawn the container
func (c *Filter) getCommand() (string, []string) {
// run the container using docker. this is simpler than using the docker
// libraries, and ensures things like auth work the same as if the container
// was run from the cli.
network := "none"
if c.Network != "" {
network = c.Network
}
args := []string{"run",
"--rm", // delete the container afterward
"-i", "-a", "STDIN", "-a", "STDOUT", "-a", "STDERR", // attach stdin, stdout, stderr
"--network", network,
// added security options
"--user", "nobody", // run as nobody
"--security-opt=no-new-privileges", // don't allow the user to escalate privileges
// note: don't make fs readonly because things like heredoc rely on writing tmp files
}
// TODO(joncwong): Allow StorageMount fields to have default values.
for _, storageMount := range c.StorageMounts {
args = append(args, "--mount", storageMount.String())
}
os.Setenv("LOG_TO_STDERR", "true")
os.Setenv("STRUCTURED_RESULTS", "true")
// export the local environment vars to the container
for _, pair := range os.Environ() {
args = append(args, "-e", strings.Split(pair, "=")[0])
}
a := append(args, c.Image)
return "docker", a
}

View File

@@ -0,0 +1,203 @@
// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package container
import (
"bytes"
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
func TestFilter_setupExec(t *testing.T) {
var tests = []struct {
name string
functionConfig string
expectedArgs []string
instance Filter
}{
{
name: "command",
functionConfig: `apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
`,
expectedArgs: []string{
"run",
"--rm",
"-i", "-a", "STDIN", "-a", "STDOUT", "-a", "STDERR",
"--network", "none",
"--user", "nobody",
"--security-opt=no-new-privileges",
},
instance: Filter{Image: "example.com:version"},
},
{
name: "network",
functionConfig: `apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
`,
expectedArgs: []string{
"run",
"--rm",
"-i", "-a", "STDIN", "-a", "STDOUT", "-a", "STDERR",
"--network", "test-1",
"--user", "nobody",
"--security-opt=no-new-privileges",
},
instance: Filter{Image: "example.com:version", Network: "test-1"},
},
{
name: "storage_mounts",
functionConfig: `apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
`,
expectedArgs: []string{
"run",
"--rm",
"-i", "-a", "STDIN", "-a", "STDOUT", "-a", "STDERR",
"--network", "none",
"--user", "nobody",
"--security-opt=no-new-privileges",
"--mount", fmt.Sprintf("type=%s,src=%s,dst=%s:ro", "bind", "/mount/path", "/local/"),
"--mount", fmt.Sprintf("type=%s,src=%s,dst=%s:ro", "volume", "myvol", "/local/"),
"--mount", fmt.Sprintf("type=%s,src=%s,dst=%s:ro", "tmpfs", "", "/local/"),
},
instance: Filter{
Image: "example.com:version",
StorageMounts: []runtimeutil.StorageMount{
{MountType: "bind", Src: "/mount/path", DstPath: "/local/"},
{MountType: "volume", Src: "myvol", DstPath: "/local/"},
{MountType: "tmpfs", Src: "", DstPath: "/local/"},
},
},
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
cfg, err := yaml.Parse(tt.functionConfig)
if !assert.NoError(t, err) {
t.FailNow()
}
tt.instance.Exec.FunctionConfig = cfg
os.Setenv("KYAML_TEST", "FOO")
tt.instance.setupExec()
// configure expected env
for _, e := range os.Environ() {
// the process env
tt.expectedArgs = append(tt.expectedArgs, "-e", strings.Split(e, "=")[0])
}
tt.expectedArgs = append(tt.expectedArgs, tt.instance.Image)
if !assert.Equal(t, "docker", tt.instance.Exec.Path) {
t.FailNow()
}
if !assert.Equal(t, tt.expectedArgs, tt.instance.Exec.Args) {
t.FailNow()
}
})
}
}
func TestFilter_Filter(t *testing.T) {
cfg, err := yaml.Parse(`apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
`)
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
}
instance := Filter{}
instance.Exec.FunctionConfig = cfg
instance.Exec.Path = "sed"
instance.Exec.Args = []string{"s/Deployment/StatefulSet/g"}
output, err := instance.Filter(input)
if !assert.NoError(t, err) {
t.FailNow()
}
b := &bytes.Buffer{}
err = kio.ByteWriter{Writer: b, KeepReaderAnnotations: true}.Write(output)
if !assert.NoError(t, err) {
t.FailNow()
}
if !assert.Equal(t, `apiVersion: apps/v1
kind: StatefulSet
metadata:
name: deployment-foo
annotations:
config.kubernetes.io/index: '0'
config.kubernetes.io/path: 'statefulset_deployment-foo.yaml'
---
apiVersion: v1
kind: Service
metadata:
name: service-foo
annotations:
config.kubernetes.io/index: '1'
config.kubernetes.io/path: 'service_service-foo.yaml'
`, b.String()) {
t.FailNow()
}
}
func TestFilter_String(t *testing.T) {
instance := Filter{Image: "foo"}
if !assert.Equal(t, "foo", instance.String()) {
t.FailNow()
}
instance.Exec.DeferFailure = true
if !assert.Equal(t, "foo deferFailure: true", instance.String()) {
t.FailNow()
}
}
func TestFilter_ExitCode(t *testing.T) {
instance := Filter{}
instance.Exec.Path = "/not/real/command"
instance.Exec.DeferFailure = true
_, err := instance.Filter(nil)
if !assert.NoError(t, err) {
t.FailNow()
}
if !assert.EqualError(t, instance.GetExit(), "fork/exec /not/real/command: no such file or directory") {
t.FailNow()
}
}