Improvements to kyaml fn framework

This commit creates a new version of the alpha configuration functions framework. Goals include:
- Make it easy to build multi-version APIs with the framework (not previously facilitated at all).
- Simplify the framework's APIs where redundant configuration options exist (leaving the most powerful, replacing others with helpers to maintain usability they provided).
- Make the Framework's APIs more consistent (e.g. between the various template types, usage of kio.Filter, field names)
- Decouple responsibilities (e.g. command creation, resource list processing, generation of templating functions).
- Make the framework even more powerfully pluggable (e.g. any kio.Filter can be a selector, and the selector the framework provides is itself a filter built from reusable abstractions).
- Improve documentation.
- Make container patches merge fields (notably list fields like `env`) correctly.
This commit is contained in:
Katrina Verey
2021-01-21 17:52:45 -08:00
parent 1d524b6fbe
commit 5c4b5b1bf0
61 changed files with 4059 additions and 2624 deletions

View File

@@ -0,0 +1,166 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package command
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"github.com/spf13/cobra"
"sigs.k8s.io/kustomize/kyaml/errors"
"sigs.k8s.io/kustomize/kyaml/fn/framework"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
type CLIMode byte
const (
StandaloneEnabled CLIMode = iota
StandaloneDisabled
)
// Build returns a cobra.Command to run a function.
//
// The cobra.Command reads the input from STDIN, invokes the provided processor,
// and then writes the output to STDOUT.
//
// The cobra.Command has a boolean `--stack` flag to print stack traces on failure.
//
// By default, invoking the returned cobra.Command with arguments triggers "standalone" mode.
// In this mode:
// - The first argument must be the name of a file containing the FunctionConfig.
// - The remaining arguments must be filenames containing input resources for ResourceList.Items.
// - The argument "-", if present, will cause resources to be read from STDIN as well.
// The output will be a raw stream of resources (not wrapped in a List type).
// Example usage: `cat input1.yaml | go run main.go config.yaml input2.yaml input3.yaml -`
//
// If mode is `StandaloneDisabled`, all arguments are ignored, and STDIN must contain
// a Kubernetes List type. To pass a function config in this mode, use a ResourceList as the input.
// The output will be of the same type as the input (e.g. ResourceList).
// Example usage: `cat resource_list.yaml | go run main.go`
//
// By default, any error returned by the ResourceListProcessor will be printed to STDERR.
// Set noPrintError to true to suppress this.
func Build(p framework.ResourceListProcessor, mode CLIMode, noPrintError bool) *cobra.Command {
cmd := cobra.Command{}
var printStack bool
cmd.Flags().BoolVar(&printStack, "stack", false, "print the stack trace on failure")
cmd.Args = cobra.MinimumNArgs(0)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.RunE = func(cmd *cobra.Command, args []string) error {
var readers []io.Reader
rw := &kio.ByteReadWriter{
Writer: cmd.OutOrStdout(),
KeepReaderAnnotations: true,
}
if len(args) > 0 && mode == StandaloneEnabled {
// Don't keep the reader annotations if we are in standalone mode
rw.KeepReaderAnnotations = false
// Don't wrap the resources in a resourceList -- we are in
// standalone mode and writing to stdout to be applied
rw.NoWrap = true
for i := range args {
// the first argument is the resourceList.FunctionConfig
if i == 0 {
var err error
if rw.FunctionConfig, err = functionConfigFromFile(args[0]); err != nil {
return errors.Wrap(err)
}
continue
}
if args[i] == "-" {
readers = append([]io.Reader{cmd.InOrStdin()}, readers...)
} else {
readers = append(readers, &deferredFileReader{path: args[i]})
}
}
} else {
// legacy kustomize plugin input style
legacyPlugin := os.Getenv("KUSTOMIZE_PLUGIN_CONFIG_STRING")
if legacyPlugin != "" && rw.FunctionConfig != nil {
if err := yaml.Unmarshal([]byte(legacyPlugin), rw.FunctionConfig); err != nil {
return err
}
}
readers = append(readers, cmd.InOrStdin())
}
rw.Reader = io.MultiReader(readers...)
err := framework.Execute(p, rw)
if err != nil && !noPrintError {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%v", err)
}
// print the stack if requested
if s := errors.GetStack(err); printStack && s != "" {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), s)
}
return err
}
return &cmd
}
// AddGenerateDockerfile adds a "gen" subcommand to create a Dockerfile for building
// the function into a container image.
// The gen command takes one argument: the directory where the Dockerfile will be created.
//
// go run main.go gen DIR/
func AddGenerateDockerfile(cmd *cobra.Command) {
gen := &cobra.Command{
Use: "gen [DIR]",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return ioutil.WriteFile(filepath.Join(args[0], "Dockerfile"), []byte(`FROM golang:1.15-alpine as builder
ENV CGO_ENABLED=0
WORKDIR /go/src/
COPY . .
RUN go build -tags netgo -ldflags '-w' -v -o /usr/local/bin/function ./
FROM alpine:latest
COPY --from=builder /usr/local/bin/function /usr/local/bin/function
CMD ["function"]
`), 0600)
},
}
cmd.AddCommand(gen)
}
func functionConfigFromFile(file string) (*yaml.RNode, error) {
b, err := ioutil.ReadFile(file)
if err != nil {
return nil, errors.WrapPrefixf(err, "unable to read configuration file %q", file)
}
fc, err := yaml.Parse(string(b))
if err != nil {
return nil, errors.WrapPrefixf(err, "unable to parse configuration file %q", file)
}
return fc, nil
}
type deferredFileReader struct {
path string
srcReader io.Reader
}
func (fr *deferredFileReader) Read(dest []byte) (int, error) {
if fr.srcReader == nil {
src, err := ioutil.ReadFile(fr.path)
if err != nil {
return 0, errors.WrapPrefixf(err, "unable to read input file %s", fr.path)
}
fr.srcReader = bytes.NewReader(src)
}
return fr.srcReader.Read(dest)
}

View File

@@ -0,0 +1,164 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package command_test
import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/kustomize/kyaml/fn/framework"
"sigs.k8s.io/kustomize/kyaml/fn/framework/command"
"sigs.k8s.io/kustomize/kyaml/fn/framework/frameworktestutil"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
func TestCommand_dockerfile(t *testing.T) {
d, err := ioutil.TempDir("", "kustomize")
if !assert.NoError(t, err) {
t.FailNow()
}
defer os.RemoveAll(d)
// create a function
cmd := command.Build(&framework.SimpleProcessor{}, command.StandaloneEnabled, false)
// add the Dockerfile generator
command.AddGenerateDockerfile(cmd)
// generate the Dockerfile
cmd.SetArgs([]string{"gen", d})
if !assert.NoError(t, cmd.Execute()) {
t.FailNow()
}
b, err := ioutil.ReadFile(filepath.Join(d, "Dockerfile"))
if !assert.NoError(t, err) {
t.FailNow()
}
expected := `FROM golang:1.15-alpine as builder
ENV CGO_ENABLED=0
WORKDIR /go/src/
COPY . .
RUN go build -tags netgo -ldflags '-w' -v -o /usr/local/bin/function ./
FROM alpine:latest
COPY --from=builder /usr/local/bin/function /usr/local/bin/function
CMD ["function"]
`
if !assert.Equal(t, expected, string(b)) {
t.FailNow()
}
}
// TestCommand_standalone tests the framework works in standalone mode
func TestCommand_standalone(t *testing.T) {
var config struct {
A string `json:"a" yaml:"a"`
}
fn := func(items []*yaml.RNode) ([]*yaml.RNode, error) {
items = append(items, yaml.MustParse(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar1
namespace: default
annotations:
foo: bar1
`))
for i := range items {
err := items[i].PipeE(yaml.SetAnnotation("a", config.A))
if err != nil {
return nil, err
}
}
return items, nil
}
cmdFn := func() *cobra.Command {
return command.Build(&framework.SimpleProcessor{Filter: kio.FilterFunc(fn), Config: &config}, command.StandaloneEnabled, false)
}
tc := frameworktestutil.CommandResultsChecker{Command: cmdFn}
tc.Assert(t)
}
func TestCommand_standalone_stdin(t *testing.T) {
var config struct {
A string `json:"a" yaml:"a"`
}
p := &framework.SimpleProcessor{
Config: &config,
Filter: kio.FilterFunc(func(items []*yaml.RNode) ([]*yaml.RNode, error) {
items = append(items, yaml.MustParse(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar2
namespace: default
annotations:
foo: bar2
`))
for i := range items {
err := items[i].PipeE(yaml.SetAnnotation("a", config.A))
if err != nil {
return nil, err
}
}
return items, nil
}),
}
cmd := command.Build(p, command.StandaloneEnabled, false)
cmd.SetIn(bytes.NewBufferString(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar1
namespace: default
annotations:
foo: bar1
spec:
replicas: 1
`))
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{filepath.Join("testdata", "standalone", "config.yaml"), "-"})
require.NoError(t, cmd.Execute())
require.Equal(t, strings.TrimSpace(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar1
namespace: default
annotations:
foo: bar1
a: 'b'
spec:
replicas: 1
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar2
namespace: default
annotations:
foo: bar2
a: 'b'
`), strings.TrimSpace(out.String()))
}

View File

@@ -0,0 +1,90 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
// Package command contains a builder for creating cobra.Commands based on configuration functions
// written using the kyaml function framework. The commands this package generates can be used as
// standalone executables or as part of a configuration management pipeline that complies with the
// Configuration Functions Specification (e.g. Kustomize generators or transformers):
// https://github.com/kubernetes-sigs/kustomize/blob/master/cmd/config/docs/api-conventions/functions-spec.md
//
// Example standalone usage
//
// Function template input:
//
// # config.yaml -- this is the input to the template
// apiVersion: example.com/v1alpha1
// kind: Example
// Key: a
// Value: b
//
// Additional function inputs:
//
// # patch.yaml -- this will be applied as a patch
// apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// namespace: default
// annotations:
// patch-key: patch-value
//
// Manually run the function:
//
// # build the function
// $ go build example-fn/
//
// # run the function
// $ ./example-fn config.yaml patch.yaml
//
// Go implementation
//
// // example-fn/main.go
// func main() {
// // Define the template used to generate resources
// p := framework.TemplateProcessor{
// MergeResources: true, // apply inputs as patches to the template output
// TemplateData: new(struct {
// Key string `json:"key" yaml:"key"`
// Value string `json:"value" yaml:"value"`
// }),
// ResourceTemplates: []framework.ResourceTemplate{{
// Templates: framework.StringTemplates(`
// apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// namespace: default
// annotations:
// {{ .Key }}: {{ .Value }}
// `)}},
// }
//
// // Run the command
// if err := command.Build(p, command.StandaloneEnabled, true).Execute(); err != nil {
// fmt.Fprintf(cmd.ErrOrStderr(), "%v\n", err)
// os.Exit(1)
// }
// }
//
// Example function implementation using command.Build with flag input
//
// func main() {
// var value string
// fn := func(rl *framework.ResourceList) error {
// for i := range rl.Items {
// // set the annotation on each resource item
// if err := rl.Items[i].PipeE(yaml.SetAnnotation("value", value)); err != nil {
// return err
// }
// }
// return nil
// }
// cmd := command.Build(framework.ResourceListProcessorFunc(fn), command.StandaloneEnabled, false)
// cmd.Flags().StringVar(&value, "value", "", "annotation value")
//
// if err := cmd.Execute(); err != nil {
// fmt.Println(err)
// os.Exit(1)
// }
// }
package command

View File

@@ -0,0 +1,382 @@
// Copyright 2021 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
package command_test
import (
"bytes"
"fmt"
"sigs.k8s.io/kustomize/kyaml/fn/framework"
"sigs.k8s.io/kustomize/kyaml/fn/framework/command"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
const service = "Service"
// ExampleBuild_modify implements a function that sets an annotation on each resource.
// The annotation value is configured via ResourceList.FunctionConfig.
func ExampleBuild_modify() {
// create a struct matching the structure of ResourceList.FunctionConfig to hold its data
var config struct {
Data map[string]string `yaml:"data"`
}
fn := func(items []*yaml.RNode) ([]*yaml.RNode, error) {
for i := range items {
// set the annotation on each resource item
err := items[i].PipeE(yaml.SetAnnotation("value", config.Data["value"]))
if err != nil {
return nil, err
}
}
return items, nil
}
p := framework.SimpleProcessor{Filter: kio.FilterFunc(fn), Config: &config}
cmd := command.Build(p, command.StandaloneDisabled, false)
// for testing purposes only -- normally read from stdin when Executing
cmd.SetIn(bytes.NewBufferString(`
apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
# items are provided as nodes
items:
- apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
- apiVersion: v1
kind: Service
metadata:
name: foo
functionConfig:
apiVersion: v1
kind: ConfigMap
data:
value: baz
`))
// run the command
if err := cmd.Execute(); err != nil {
panic(err)
}
// Output:
// apiVersion: config.kubernetes.io/v1alpha1
// kind: ResourceList
// items:
// - apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// annotations:
// value: 'baz'
// - apiVersion: v1
// kind: Service
// metadata:
// name: foo
// annotations:
// value: 'baz'
// functionConfig:
// apiVersion: v1
// kind: ConfigMap
// data:
// value: baz
}
// ExampleBuild_generateReplace generates a resource from a FunctionConfig.
// If the resource already exists, it replaces the resource with a new copy.
func ExampleBuild_generateReplace() {
// function API definition which will be parsed from the ResourceList.FunctionConfig
// read from stdin
type Spec struct {
Name string `yaml:"name,omitempty"`
}
type ExampleServiceGenerator struct {
Spec Spec `yaml:"spec,omitempty"`
}
functionConfig := &ExampleServiceGenerator{}
// function implementation -- generate a Service resource
p := &framework.SimpleProcessor{
Config: functionConfig,
Filter: kio.FilterFunc(func(items []*yaml.RNode) ([]*yaml.RNode, error) {
var newNodes []*yaml.RNode
for i := range items {
meta, err := items[i].GetMeta()
if err != nil {
return nil, err
}
// something we already generated, remove it from the list so we regenerate it
if meta.Name == functionConfig.Spec.Name &&
meta.Kind == service &&
meta.APIVersion == "v1" {
continue
}
newNodes = append(newNodes, items[i])
}
// generate the resource
n, err := yaml.Parse(fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
`, functionConfig.Spec.Name))
if err != nil {
return nil, err
}
newNodes = append(newNodes, n)
return newNodes, nil
}),
}
cmd := command.Build(p, command.StandaloneDisabled, false)
// for testing purposes only -- normally read from stdin when Executing
cmd.SetIn(bytes.NewBufferString(`
apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
# items are provided as nodes
items:
- apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
functionConfig:
apiVersion: example.com/v1alpha1
kind: ExampleServiceGenerator
spec:
name: bar
`))
// run the command
if err := cmd.Execute(); err != nil {
panic(err)
}
// Output:
// apiVersion: config.kubernetes.io/v1alpha1
// kind: ResourceList
// items:
// - apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// - apiVersion: v1
// kind: Service
// metadata:
// name: bar
// functionConfig:
// apiVersion: example.com/v1alpha1
// kind: ExampleServiceGenerator
// spec:
// name: bar
}
// ExampleBuild_generateUpdate generates a resource, updating the previously generated
// copy rather than replacing it.
//
// Note: This will keep manual edits to the previously generated copy.
func ExampleBuild_generateUpdate() {
// function API definition which will be parsed from the ResourceList.FunctionConfig
// read from stdin
type Spec struct {
Name string `yaml:"name,omitempty"`
Annotations map[string]string `yaml:"annotations,omitempty"`
}
type ExampleServiceGenerator struct {
Spec Spec `yaml:"spec,omitempty"`
}
functionConfig := &ExampleServiceGenerator{}
// function implementation -- generate or update a Service resource
fn := func(items []*yaml.RNode) ([]*yaml.RNode, error) {
var found bool
for i := range items {
meta, err := items[i].GetMeta()
if err != nil {
return nil, err
}
// something we already generated, reconcile it to make sure it matches what
// is specified by the FunctionConfig
if meta.Name == functionConfig.Spec.Name &&
meta.Kind == service &&
meta.APIVersion == "v1" {
// set some values
for k, v := range functionConfig.Spec.Annotations {
err := items[i].PipeE(yaml.SetAnnotation(k, v))
if err != nil {
return nil, err
}
}
found = true
break
}
}
if found {
return items, nil
}
// generate the resource if not found
n, err := yaml.Parse(fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
`, functionConfig.Spec.Name))
if err != nil {
return nil, err
}
for k, v := range functionConfig.Spec.Annotations {
err := n.PipeE(yaml.SetAnnotation(k, v))
if err != nil {
return nil, err
}
}
items = append(items, n)
return items, nil
}
p := &framework.SimpleProcessor{Config: functionConfig, Filter: kio.FilterFunc(fn)}
cmd := command.Build(p, command.StandaloneDisabled, false)
// for testing purposes only -- normally read from stdin when Executing
cmd.SetIn(bytes.NewBufferString(`
apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
# items are provided as nodes
items:
- apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
- apiVersion: v1
kind: Service
metadata:
name: bar
functionConfig:
apiVersion: example.com/v1alpha1
kind: ExampleServiceGenerator
spec:
name: bar
annotations:
a: b
`))
// run the command
if err := cmd.Execute(); err != nil {
panic(err)
}
// Output:
// apiVersion: config.kubernetes.io/v1alpha1
// kind: ResourceList
// items:
// - apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// - apiVersion: v1
// kind: Service
// metadata:
// name: bar
// annotations:
// a: 'b'
// functionConfig:
// apiVersion: example.com/v1alpha1
// kind: ExampleServiceGenerator
// spec:
// name: bar
// annotations:
// a: b
}
// ExampleBuild_validate validates that all Deployment resources have the replicas field set.
// If any Deployments do not contain spec.replicas, then the function will return results
// which will be set on ResourceList.results
func ExampleBuild_validate() {
fn := func(rl *framework.ResourceList) error {
// validation results
var validationResults []framework.ResultItem
// validate that each Deployment resource has spec.replicas set
for i := range rl.Items {
// only check Deployment resources
meta, err := rl.Items[i].GetMeta()
if err != nil {
return err
}
if meta.Kind != "Deployment" {
continue
}
// lookup replicas field
r, err := rl.Items[i].Pipe(yaml.Lookup("spec", "replicas"))
if err != nil {
return err
}
// check replicas not specified
if r != nil {
continue
}
validationResults = append(validationResults, framework.ResultItem{
Severity: framework.Error,
Message: "field is required",
ResourceRef: meta,
Field: framework.Field{
Path: "spec.replicas",
SuggestedValue: "1",
},
})
}
if len(validationResults) > 0 {
rl.Result = &framework.Result{
Name: "replicas-validator",
Items: validationResults,
}
}
return rl.Result
}
cmd := command.Build(framework.ResourceListProcessorFunc(fn), command.StandaloneDisabled, true)
// for testing purposes only -- normally read from stdin when Executing
cmd.SetIn(bytes.NewBufferString(`
apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
# items are provided as nodes
items:
- apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
`))
// run the command
if err := cmd.Execute(); err != nil {
// normally exit 1 here
}
// Output:
// apiVersion: config.kubernetes.io/v1alpha1
// kind: ResourceList
// items:
// - apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// results:
// name: replicas-validator
// items:
// - message: field is required
// severity: error
// resourceRef:
// apiVersion: apps/v1
// kind: Deployment
// metadata:
// name: foo
// field:
// path: spec.replicas
// suggestedValue: "1"
}

View File

@@ -0,0 +1,6 @@
# Copyright 2019 The Kubernetes Authors.
# SPDX-License-Identifier: Apache-2.0
apiVersion: example.com/v1alpha1
kind: Foo
a: b

View File

@@ -0,0 +1,20 @@
# Copyright 2019 The Kubernetes Authors.
# SPDX-License-Identifier: Apache-2.0
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar2
namespace: default
annotations:
foo: bar2
a: 'b'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar1
namespace: default
annotations:
foo: bar1
a: 'b'

View File

@@ -0,0 +1,10 @@
# Copyright 2019 The Kubernetes Authors.
# SPDX-License-Identifier: Apache-2.0
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar2
namespace: default
annotations:
foo: bar2