src: direct serialization of Function metadata as func.yaml (#641)

* src: directly serialize Function metadata as func.yaml

Functions now save directly to func.yaml using .Write().
Fixes a serialization error where defaults were not respected on load.
Moves runtime and template defaults into function constructor.
Extracts Function validation (was config validation) into separate functions.
Extracts associated test files (validation) into separate unit test files.
Updates schema generator to use Function

* comment spelling and re-enabling tests
This commit is contained in:
Luke Kingland 2021-11-17 23:18:35 +09:00 committed by GitHub
parent 3935747b91
commit c2e1b769cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2123 additions and 2072 deletions

View File

@ -430,9 +430,9 @@ func (c *Client) New(ctx context.Context, cfg Function) (err error) {
// Create a new Function project locally using the settings provided on a
// Function object.
func (c *Client) Create(f Function) (err error) {
func (c *Client) Create(cfg Function) (err error) {
// Create project root directory, if it doesn't already exist
if err = os.MkdirAll(f.Root, 0755); err != nil {
if err = os.MkdirAll(cfg.Root, 0755); err != nil {
return
}
@ -442,11 +442,11 @@ func (c *Client) Create(f Function) (err error) {
// immediately exit with error (prior to actual creation) if this is
// a Function already initialized at that path (Create should never
// clobber a pre-existing Function)
defaults, err := NewFunction(f.Root)
f, err := NewFunctionFromDefaults(cfg)
if err != nil {
return
}
if defaults.Initialized() {
if f.Initialized() {
err = fmt.Errorf("Function at '%v' already initialized", f.Root)
return
}
@ -463,16 +463,6 @@ func (c *Client) Create(f Function) (err error) {
return
}
// Assert runtime was provided, or default.
if f.Runtime == "" {
f.Runtime = DefaultRuntime
}
// Assert template name was provided, or default.
if f.Template == "" {
f.Template = DefaultTemplate
}
// Write out the template for a Function
// returns a Function which may be mutated based on the content of
// the template (default Function, builders, buildpacks, etc).
@ -483,7 +473,7 @@ func (c *Client) Create(f Function) (err error) {
// Mark it as having been created via this client library and Write (save)
f.Created = time.Now()
if err = writeConfig(f); err != nil {
if err = f.Write(); err != nil {
return
}
@ -544,9 +534,9 @@ func (c *Client) Build(ctx context.Context, path string) (err error) {
return
}
// Write out config, which will now contain a populated image tag
// if it had not already
if err = writeConfig(f); err != nil {
// Write (save) - Serialize the Function to disk
// Will now contain populated image tag.
if err = f.Write(); err != nil {
return
}
@ -585,9 +575,9 @@ func (c *Client) Deploy(ctx context.Context, path string) (err error) {
return
}
// Store the produced image Digest in the config
// Record the Image Digest pushed.
f.ImageDigest = imageDigest
if err = writeConfig(f); err != nil {
if err = f.Write(); err != nil {
return
}

View File

@ -38,7 +38,7 @@ func TestNew(t *testing.T) {
root := "testdata/example.com/testNew"
defer Using(t, root)()
client := fn.New(fn.WithRegistry(TestRegistry))
client := fn.New(fn.WithRegistry(TestRegistry), fn.WithVerbose(true))
if err := client.New(context.Background(), fn.Function{Root: root}); err != nil {
t.Fatal(err)
@ -58,13 +58,13 @@ func TestWritesTemplate(t *testing.T) {
}
// Assert the standard config file was written
if _, err := os.Stat(filepath.Join(root, fn.ConfigFile)); os.IsNotExist(err) {
t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.ConfigFile, root)
if _, err := os.Stat(filepath.Join(root, fn.FunctionFile)); os.IsNotExist(err) {
t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.FunctionFile, root)
}
// Assert a file from the template was written
if _, err := os.Stat(filepath.Join(root, "README.md")); os.IsNotExist(err) {
t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.ConfigFile, root)
t.Fatalf("Initialize did not result in '%v' being written to '%v'", fn.FunctionFile, root)
}
}

View File

@ -145,7 +145,7 @@ func runBuild(cmd *cobra.Command, _ []string, clientFn buildClientFn) (err error
}
// All set, let's write changes in the config to the disk
err = function.WriteConfig()
err = function.Write()
if err != nil {
return
}

View File

@ -38,7 +38,7 @@ func (s standardLoaderSaver) Load(path string) (fn.Function, error) {
}
func (s standardLoaderSaver) Save(f fn.Function) error {
return f.WriteConfig()
return f.Write()
}
var defaultLoaderSaver standardLoaderSaver

View File

@ -375,7 +375,7 @@ func runAddEnvsPrompt(ctx context.Context, f fn.Function) (err error) {
f.Envs[insertToIndex] = newEnv
}
err = f.WriteConfig()
err = f.Write()
if err == nil {
fmt.Println("Environment variable entry was added to the function configuration")
}
@ -419,7 +419,7 @@ func runRemoveEnvsPrompt(f fn.Function) (err error) {
if removed {
f.Envs = newEnvs
err = f.WriteConfig()
err = f.Write()
if err == nil {
fmt.Println("Environment variable entry was removed from the function configuration")
}

View File

@ -192,7 +192,7 @@ func runAddVolumesPrompt(ctx context.Context, f fn.Function) (err error) {
f.Volumes = append(f.Volumes, newVolume)
err = f.WriteConfig()
err = f.Write()
if err == nil {
fmt.Println("Volume entry was added to the function configuration")
}
@ -236,7 +236,7 @@ func runRemoveVolumesPrompt(f fn.Function) (err error) {
if removed {
f.Volumes = newVolumes
err = f.WriteConfig()
err = f.Write()
if err == nil {
fmt.Println("Volume entry was removed from the function configuration")
}

View File

@ -158,7 +158,7 @@ func runDeploy(cmd *cobra.Command, _ []string, clientFn deployClientFn) (err err
}
// All set, let's write changes in the config to the disk
err = function.WriteConfig()
err = function.Write()
if err != nil {
return
}

View File

@ -75,7 +75,7 @@ func runRun(cmd *cobra.Command, args []string, clientFn runClientFn) (err error)
return
}
err = function.WriteConfig()
err = function.Write()
if err != nil {
return
}

535
config.go
View File

@ -1,534 +1,5 @@
package function
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v2"
"k8s.io/apimachinery/pkg/api/resource"
"knative.dev/kn-plugin-func/utils"
)
// ConfigFile is the name of the config's serialized form.
const ConfigFile = "func.yaml"
var (
regWholeSecret = regexp.MustCompile(`^{{\s*secret:((?:\w|['-]\w)+)\s*}}$`)
regKeyFromSecret = regexp.MustCompile(`^{{\s*secret:((?:\w|['-]\w)+):([-._a-zA-Z0-9]+)\s*}}$`)
regWholeConfigMap = regexp.MustCompile(`^{{\s*configMap:((?:\w|['-]\w)+)\s*}}$`)
regKeyFromConfigMap = regexp.MustCompile(`^{{\s*configMap:((?:\w|['-]\w)+):([-._a-zA-Z0-9]+)\s*}}$`)
regLocalEnv = regexp.MustCompile(`^{{\s*env:(\w+)\s*}}$`)
)
type Volume struct {
Secret *string `yaml:"secret,omitempty" jsonschema:"oneof_required=secret"`
ConfigMap *string `yaml:"configMap,omitempty" jsonschema:"oneof_required=configmap"`
Path *string `yaml:"path"`
}
func (v Volume) String() string {
if v.ConfigMap != nil {
return fmt.Sprintf("ConfigMap \"%s\" mounted at path: \"%s\"", *v.ConfigMap, *v.Path)
} else if v.Secret != nil {
return fmt.Sprintf("Secret \"%s\" mounted at path: \"%s\"", *v.Secret, *v.Path)
}
return ""
}
type Env struct {
Name *string `yaml:"name,omitempty" jsonschema:"pattern=^[-._a-zA-Z][-._a-zA-Z0-9]*$"`
Value *string `yaml:"value"`
}
func (e Env) String() string {
if e.Name == nil && e.Value != nil {
match := regWholeSecret.FindStringSubmatch(*e.Value)
if len(match) == 2 {
return fmt.Sprintf("All key=value pairs from Secret \"%s\"", match[1])
}
match = regWholeConfigMap.FindStringSubmatch(*e.Value)
if len(match) == 2 {
return fmt.Sprintf("All key=value pairs from ConfigMap \"%s\"", match[1])
}
} else if e.Name != nil && e.Value != nil {
match := regKeyFromSecret.FindStringSubmatch(*e.Value)
if len(match) == 3 {
return fmt.Sprintf("Env \"%s\" with value set from key \"%s\" from Secret \"%s\"", *e.Name, match[2], match[1])
}
match = regKeyFromConfigMap.FindStringSubmatch(*e.Value)
if len(match) == 3 {
return fmt.Sprintf("Env \"%s\" with value set from key \"%s\" from ConfigMap \"%s\"", *e.Name, match[2], match[1])
}
match = regLocalEnv.FindStringSubmatch(*e.Value)
if len(match) == 2 {
return fmt.Sprintf("Env \"%s\" with value set from local env variable \"%s\"", *e.Name, match[1])
}
return fmt.Sprintf("Env \"%s\" with value \"%s\"", *e.Name, *e.Value)
}
return ""
}
type Label struct {
// Key consist of optional prefix part (ended by '/') and name part
// Prefix part validation pattern: [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
// Name part validation pattern: ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]
Key *string `yaml:"key" jsonschema:"pattern=^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\\/)?([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$"`
Value *string `yaml:"value,omitempty" jsonschema:"pattern=^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$"`
}
func (l Label) String() string {
if l.Key != nil && l.Value == nil {
return fmt.Sprintf("Label with key \"%s\"", *l.Key)
} else if l.Key != nil && l.Value != nil {
match := regLocalEnv.FindStringSubmatch(*l.Value)
if len(match) == 2 {
return fmt.Sprintf("Label with key \"%s\" and value set from local env variable \"%s\"", *l.Key, match[1])
}
return fmt.Sprintf("Label with key \"%s\" and value \"%s\"", *l.Key, *l.Value)
}
return ""
}
type Options struct {
Scale *ScaleOptions `yaml:"scale,omitempty"`
Resources *ResourcesOptions `yaml:"resources,omitempty"`
}
type ScaleOptions struct {
Min *int64 `yaml:"min,omitempty" jsonschema_extras:"minimum=0"`
Max *int64 `yaml:"max,omitempty" jsonschema_extras:"minimum=0"`
Metric *string `yaml:"metric,omitempty" jsonschema:"enum=concurrency,enum=rps"`
Target *float64 `yaml:"target,omitempty" jsonschema_extras:"minimum=0.01"`
Utilization *float64 `yaml:"utilization,omitempty" jsonschema:"minimum=1,maximum=100"`
}
type ResourcesOptions struct {
Requests *ResourcesRequestsOptions `yaml:"requests,omitempty"`
Limits *ResourcesLimitsOptions `yaml:"limits,omitempty"`
}
type ResourcesLimitsOptions struct {
CPU *string `yaml:"cpu,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
Memory *string `yaml:"memory,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
Concurrency *int64 `yaml:"concurrency,omitempty" jsonschema_extras:"minimum=0"`
}
type ResourcesRequestsOptions struct {
CPU *string `yaml:"cpu,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
Memory *string `yaml:"memory,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
}
// Config represents the serialized state of a Function's metadata.
// See the Function struct for attribute documentation.
type Config struct {
Name string `yaml:"name" jsonschema:"pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"`
Namespace string `yaml:"namespace"`
Runtime string `yaml:"runtime"`
Image string `yaml:"image"`
ImageDigest string `yaml:"imageDigest"`
Builder string `yaml:"builder"`
Builders map[string]string `yaml:"builders"`
Buildpacks []string `yaml:"buildpacks"`
HealthEndpoints HealthEndpoints `yaml:"healthEndpoints"`
Volumes []Volume `yaml:"volumes"`
BuildEnvs []Env `yaml:"buildEnvs"`
Envs []Env `yaml:"envs"`
Annotations map[string]string `yaml:"annotations"`
Options Options `yaml:"options"`
Labels []Label `yaml:"labels"`
Created time.Time `yaml:"created"`
// Add new values to the toConfig/fromConfig functions.
}
// newConfig returns a Config populated from data serialized to disk if it is
// available. Errors are returned if the path is not valid, if there are
// errors accessing an extant config file, or the contents of the file do not
// unmarshall. A missing file at a valid path does not error but returns the
// empty value of Config.
func newConfig(root string) (c Config, err error) {
filename := filepath.Join(root, ConfigFile)
if _, err = os.Stat(filename); err != nil {
// do not consider a missing config file an error. Just return.
if os.IsNotExist(err) {
err = nil
}
return
}
bb, err := ioutil.ReadFile(filename)
if err != nil {
return
}
if err = yaml.UnmarshalStrict(bb, &c); err != nil {
return c, formatUnmarshalError(err) // fail fast on sytax errors
}
return c, validateConfig(c)
}
func formatUnmarshalError(err error) error {
var (
e = err.Error()
rxp = regexp.MustCompile("not found in type .*")
header = fmt.Sprintf("'%v' is not valid:\n", ConfigFile)
)
if strings.HasPrefix(e, "yaml: unmarshal errors:") {
e = rxp.ReplaceAllString(e, "is not valid")
e = strings.Replace(e, "yaml: unmarshal errors:\n", header, 1)
} else if strings.HasPrefix(e, "yaml:") {
e = rxp.ReplaceAllString(e, "is not valid")
e = strings.Replace(e, "yaml: ", header+" ", 1)
}
return errors.New(e)
}
func validateConfig(c Config) error {
errs := [][]string{
validateVolumes(c.Volumes),
ValidateBuildEnvs(c.BuildEnvs),
ValidateEnvs(c.Envs),
validateOptions(c.Options),
ValidateLabels(c.Labels),
}
var ctr int
var b strings.Builder
b.WriteString(fmt.Sprintf("'%v' contains errors:", ConfigFile))
for _, ee := range errs {
if len(ee) > 0 {
b.WriteString("\n") // Precede each group of errors with a linebreak
}
for _, e := range ee {
ctr++
b.WriteString("\t" + e)
}
}
if ctr == 0 {
return nil // No errors were encountered
}
return errors.New(b.String())
}
// fromConfig returns a Function populated from config.
// Note that config does not include ancillary fields not serialized, such as Root.
func fromConfig(c Config) (f Function) {
return Function{
Name: c.Name,
Namespace: c.Namespace,
Runtime: c.Runtime,
Image: c.Image,
ImageDigest: c.ImageDigest,
Builder: c.Builder,
Builders: c.Builders,
Buildpacks: c.Buildpacks,
HealthEndpoints: c.HealthEndpoints,
Volumes: c.Volumes,
BuildEnvs: c.BuildEnvs,
Envs: c.Envs,
Annotations: c.Annotations,
Options: c.Options,
Labels: c.Labels,
Created: c.Created,
}
}
// toConfig serializes a Function to a config object.
func toConfig(f Function) Config {
return Config{
Name: f.Name,
Namespace: f.Namespace,
Runtime: f.Runtime,
Image: f.Image,
ImageDigest: f.ImageDigest,
Builder: f.Builder,
Builders: f.Builders,
Buildpacks: f.Buildpacks,
HealthEndpoints: f.HealthEndpoints,
Volumes: f.Volumes,
BuildEnvs: f.BuildEnvs,
Envs: f.Envs,
Annotations: f.Annotations,
Options: f.Options,
Labels: f.Labels,
Created: f.Created,
}
}
// writeConfig for the given Function out to disk at root.
func writeConfig(f Function) (err error) {
path := filepath.Join(f.Root, ConfigFile)
c := toConfig(f)
var bb []byte
if bb, err = yaml.Marshal(&c); err != nil {
return
}
return ioutil.WriteFile(path, bb, 0644)
}
// validateVolumes checks that input Volumes are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - secret: example-secret # mount Secret as Volume
// path: /etc/secret-volume
// - configMap: example-configMap # mount ConfigMap as Volume
// path: /etc/configMap-volume
func validateVolumes(volumes []Volume) (errors []string) {
for i, vol := range volumes {
if vol.Secret != nil && vol.ConfigMap != nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is not properly set, both secret '%s' and configMap '%s' can not be set at the same time",
i, *vol.Secret, *vol.ConfigMap))
} else if vol.Path == nil && vol.Secret == nil && vol.ConfigMap == nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is not properly set", i))
} else if vol.Path == nil {
if vol.Secret != nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is missing path field, only secret '%s' is set", i, *vol.Secret))
} else if vol.ConfigMap != nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is missing path field, only configMap '%s' is set", i, *vol.ConfigMap))
}
} else if vol.Path != nil && vol.Secret == nil && vol.ConfigMap == nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is missing secret or configMap field, only path '%s' is set", i, *vol.Path))
}
}
return
}
// ValidateBuildEnvs checks that input BuildEnvs are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - name: EXAMPLE1 # ENV directly from a value
// value: value1
// - name: EXAMPLE2 # ENV from the local ENV var
// value: {{ env:MY_ENV }}
func ValidateBuildEnvs(envs []Env) (errors []string) {
for i, env := range envs {
if env.Name == nil && env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is not properly set", i))
} else if env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is missing value field, only name '%s' is set", i, *env.Name))
} else {
if err := utils.ValidateEnvVarName(*env.Name); err != nil {
errors = append(errors, fmt.Sprintf("env entry #%d has invalid name set: %q; %s", i, *env.Name, err.Error()))
}
if strings.HasPrefix(*env.Value, "{{") {
// ENV from the local ENV var; {{ env:MY_ENV }}
if !regLocalEnv.MatchString(*env.Value) {
errors = append(errors,
fmt.Sprintf(
"env entry #%d with name '%s' has invalid value field set, it has '%s', but allowed is only '{{ env:MY_ENV }}'",
i, *env.Name, *env.Value))
}
}
}
}
return
}
// ValidateEnvs checks that input Envs are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - name: EXAMPLE1 # ENV directly from a value
// value: value1
// - name: EXAMPLE2 # ENV from the local ENV var
// value: {{ env:MY_ENV }}
// - name: EXAMPLE3
// value: {{ secret:secretName:key }} # ENV from a key in secret
// - value: {{ secret:secretName }} # all key-pair values from secret are set as ENV
// - name: EXAMPLE4
// value: {{ configMap:configMapName:key }} # ENV from a key in configMap
// - value: {{ configMap:configMapName }} # all key-pair values from configMap are set as ENV
func ValidateEnvs(envs []Env) (errors []string) {
for i, env := range envs {
if env.Name == nil && env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is not properly set", i))
} else if env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is missing value field, only name '%s' is set", i, *env.Name))
} else if env.Name == nil {
// all key-pair values from secret are set as ENV; {{ secret:secretName }} or {{ configMap:configMapName }}
if !regWholeSecret.MatchString(*env.Value) && !regWholeConfigMap.MatchString(*env.Value) {
errors = append(errors, fmt.Sprintf("env entry #%d has invalid value field set, it has '%s', but allowed is only '{{ secret:secretName }}' or '{{ configMap:configMapName }}'",
i, *env.Value))
}
} else {
if err := utils.ValidateEnvVarName(*env.Name); err != nil {
errors = append(errors, fmt.Sprintf("env entry #%d has invalid name set: %q; %s", i, *env.Name, err.Error()))
}
if strings.HasPrefix(*env.Value, "{{") {
// ENV from the local ENV var; {{ env:MY_ENV }}
// or
// ENV from a key in secret/configMap; {{ secret:secretName:key }} or {{ configMap:configMapName:key }}
if !regLocalEnv.MatchString(*env.Value) && !regKeyFromSecret.MatchString(*env.Value) && !regKeyFromConfigMap.MatchString(*env.Value) {
errors = append(errors,
fmt.Sprintf(
"env entry #%d with name '%s' has invalid value field set, it has '%s', but allowed is only '{{ env:MY_ENV }}', '{{ secret:secretName:key }}' or '{{ configMap:configMapName:key }}'",
i, *env.Name, *env.Value))
}
}
}
}
return
}
// ValidateLabels checks that input labels are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - key: EXAMPLE1 # label directly from a value
// value: value1
// - key: EXAMPLE2 # label from the local ENV var
// value: {{ env:MY_ENV }}
func ValidateLabels(labels []Label) (errors []string) {
for i, label := range labels {
if label.Key == nil && label.Value == nil {
errors = append(errors, fmt.Sprintf("label entry #%d is not properly set", i))
} else if label.Key == nil && label.Value != nil {
errors = append(errors, fmt.Sprintf("label entry #%d is missing key field, only value '%s' is set", i, *label.Value))
} else {
if err := utils.ValidateLabelKey(*label.Key); err != nil {
errors = append(errors, fmt.Sprintf("label entry #%d has invalid key set: %q; %s", i, *label.Key, err.Error()))
}
if label.Value != nil {
if err := utils.ValidateLabelValue(*label.Value); err != nil {
errors = append(errors, fmt.Sprintf("label entry #%d has invalid value set: %q; %s", i, *label.Value, err.Error()))
}
if strings.HasPrefix(*label.Value, "{{") {
// ENV from the local ENV var; {{ env:MY_ENV }}
if !regLocalEnv.MatchString(*label.Value) {
errors = append(errors,
fmt.Sprintf(
"label entry #%d with key '%s' has invalid value field set, it has '%s', but allowed is only '{{ env:MY_ENV }}'",
i, *label.Key, *label.Value))
} else {
match := regLocalEnv.FindStringSubmatch(*label.Value)
value := os.Getenv(match[1])
if err := utils.ValidateLabelValue(value); err != nil {
errors = append(errors, fmt.Sprintf("label entry #%d with key '%s' has invalid value when the environment is evaluated: '%s': %s", i, *label.Key, value, err.Error()))
}
}
}
}
}
}
return
}
// validateOptions checks that input Options are correctly set.
// Returns array of error messages, empty if no errors are found
func validateOptions(options Options) (errors []string) {
// options.scale
if options.Scale != nil {
if options.Scale.Min != nil {
if *options.Scale.Min < 0 {
errors = append(errors, fmt.Sprintf("options field \"scale.min\" has invalid value set: %d, the value must be greater than \"0\"",
*options.Scale.Min))
}
}
if options.Scale.Max != nil {
if *options.Scale.Max < 0 {
errors = append(errors, fmt.Sprintf("options field \"scale.max\" has invalid value set: %d, the value must be greater than \"0\"",
*options.Scale.Max))
}
}
if options.Scale.Min != nil && options.Scale.Max != nil {
if *options.Scale.Max < *options.Scale.Min {
errors = append(errors, "options field \"scale.max\" value must be greater or equal to \"scale.min\"")
}
}
if options.Scale.Metric != nil {
if *options.Scale.Metric != "concurrency" && *options.Scale.Metric != "rps" {
errors = append(errors, fmt.Sprintf("options field \"scale.metric\" has invalid value set: %s, allowed is only \"concurrency\" or \"rps\"",
*options.Scale.Metric))
}
}
if options.Scale.Target != nil {
if *options.Scale.Target < 0.01 {
errors = append(errors, fmt.Sprintf("options field \"scale.target\" has value set to \"%f\", but it must not be less than 0.01",
*options.Scale.Target))
}
}
if options.Scale.Utilization != nil {
if *options.Scale.Utilization < 1 || *options.Scale.Utilization > 100 {
errors = append(errors,
fmt.Sprintf("options field \"scale.utilization\" has value set to \"%f\", but it must not be less than 1 or greater than 100",
*options.Scale.Utilization))
}
}
}
// options.resource
if options.Resources != nil {
// options.resource.requests
if options.Resources.Requests != nil {
if options.Resources.Requests.CPU != nil {
_, err := resource.ParseQuantity(*options.Resources.Requests.CPU)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.requests.cpu\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Requests.CPU, err.Error()))
}
}
if options.Resources.Requests.Memory != nil {
_, err := resource.ParseQuantity(*options.Resources.Requests.Memory)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.requests.memory\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Requests.Memory, err.Error()))
}
}
}
// options.resource.limits
if options.Resources.Limits != nil {
if options.Resources.Limits.CPU != nil {
_, err := resource.ParseQuantity(*options.Resources.Limits.CPU)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.limits.cpu\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Limits.CPU, err.Error()))
}
}
if options.Resources.Limits.Memory != nil {
_, err := resource.ParseQuantity(*options.Resources.Limits.Memory)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.limits.memory\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Limits.Memory, err.Error()))
}
}
if options.Resources.Limits.Concurrency != nil {
if *options.Resources.Limits.Concurrency < 0 {
errors = append(errors, fmt.Sprintf("options field \"resources.limits.concurrency\" has value set to \"%d\", but it must not be less than 0",
*options.Resources.Limits.Concurrency))
}
}
}
}
return
}
// Config is local and global configuration which is not "part of the Function"
// and is thus not likely to be tracked in source control.
type Config struct{}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
When a Function is created, an example implementation and a Function metadata file are written into the new Function's working directory. Together, these files are referred to as the Function's Template. Included are the templates 'http' and 'events' for each supported language runtime.
These embedded templates are minimal by design. The Function contains a minimum of external dependencies, and the 'func.yaml' defines a final environment within which the Funciton will execute that is devoid of any extraneous packages or services.
These embedded templates are minimal by design. The Function contains a minimum of external dependencies, and the 'func.yaml' defines a final environment within which the Function will execute that is devoid of any extraneous packages or services.
To make use of more complex initial Function implementions, or to define runtime environments with arbitrarily complex requirements, the templates system is fully pluggable.

View File

@ -6,26 +6,32 @@ import (
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v2"
)
// FunctionFile is the file used for the serialized form of a Function.
const FunctionFile = "func.yaml"
type Function struct {
// Root on disk at which to find/create source and config files.
Root string
Root string `yaml:"-"`
// Name of the Function. If not provided, path derivation is attempted when
// requried (such as for initialization).
Name string
Name string `yaml:"name" jsonschema:"pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"`
// Namespace into which the Function is deployed on supported platforms.
Namespace string
Namespace string `yaml:"namespace"`
// Runtime is the language plus context. nodejs|go|quarkus|rust etc.
Runtime string
Runtime string `yaml:"runtime"`
// Template for the Function.
Template string
Template string `yaml:"-"`
// Registry at which to store interstitial containers, in the form
// [registry]/[user].
@ -40,42 +46,42 @@ type Function struct {
// alice/my.function.name
// If Image is provided, it overrides the default of concatenating
// "Registry+Name:latest" to derive the Image.
Image string
Image string `yaml:"image"`
// SHA256 hash of the latest image that has been built
ImageDigest string
ImageDigest string `yaml:"imageDigest"`
// Builder represents the CNCF Buildpack builder image for a function
Builder string
Builder string `yaml:"builder"`
// Map containing known builders.
// e.g. { "jvm": "docker.io/example/quarkus-jvm-builder" }
Builders map[string]string
Builders map[string]string `yaml:"builders"`
// Optional list of buildpacks to use when building the function
Buildpacks []string
Buildpacks []string `yaml:"buildpacks"`
// List of volumes to be mounted to the function
Volumes []Volume
Volumes []Volume `yaml:"volumes"`
// Build Env variables to be set
BuildEnvs []Env
BuildEnvs []Env `yaml:"buildEnvs"`
// Env variables to be set
Envs []Env
Envs []Env `yaml:"envs"`
// Map containing user-supplied annotations
// Example: { "division": "finance" }
Annotations map[string]string
Annotations map[string]string `yaml:"annotations"`
// Options to be set on deployed function (scaling, etc.)
Options Options
Options Options `yaml:"options"`
// Map of user-supplied labels
Labels []Label
Labels []Label `yaml:"labels"`
// Health endpoints specified by the language pack
HealthEndpoints HealthEndpoints
HealthEndpoints HealthEndpoints `yaml:"healthEndpoints"`
// Created time is the moment that creation was successfully completed
// according to the client which is in charge of what constitutes being
@ -83,66 +89,88 @@ type Function struct {
Created time.Time
}
// NewFunction loads a Function from a path on disk. use .Initialized() to determine if
// the path contained an initialized Function.
// NewFunction creates a Function struct whose attributes are loaded from the
// configuraiton located at path.
// NewFunction loads a Function from a path on disk.
// Errors are returned if the path is not valid, the serialized field could not
// be accessed, or if the contents of the file could not be unmarshaled into a
// Function. A valid path with no associated FunctionFile is not an error but
// rather returns a Function with static defaults set, and will return false
// from .Initialized().
func NewFunction(root string) (f Function, err error) {
// Expand the passed root to its absolute path (default current dir)
if root, err = filepath.Abs(root); err != nil {
return
}
// Load a Config from the given absolute path
c, err := newConfig(root)
if err != nil {
return
}
// Let's set Function name, if it is not already set
if c.Name == "" {
pathParts := strings.Split(strings.TrimRight(root, string(os.PathSeparator)), string(os.PathSeparator))
c.Name = pathParts[len(pathParts)-1]
}
// set Function to the value of the config loaded from disk.
f = fromConfig(c)
// The only value not included in the config is the effective path on disk
f.Root = root
return
// NewFunction is essentially a convenience/decorator over the more fully-
// featured constructor which takes a full function object as defaults.
return NewFunctionFromDefaults(Function{Root: root})
}
// WriteConfig writes this Function's configuration to disk.
func (f Function) WriteConfig() (err error) {
return writeConfig(f)
// NewFunctionFromDefaults is equivalent to calling NewFunction, but will use
// the provided function as defaults.
func NewFunctionFromDefaults(f Function) (Function, error) {
var err error
if f.Runtime == "" {
f.Runtime = DefaultRuntime
}
if f.Template == "" {
f.Template = DefaultTemplate
}
if f.Root, err = filepath.Abs(f.Root); err != nil {
return f, err
}
if f.Name == "" {
f.Name = nameFromPath(f.Root)
}
return unmarshalFunction(f)
}
// nameFromPath returns the default name for a Function derived from a path.
// This consists of the last directory in the given path, if derivable (empty
// paths, paths consisting of all slashes, etc. return the zero value "")
func nameFromPath(path string) string {
pathParts := strings.Split(strings.TrimRight(path, string(os.PathSeparator)), string(os.PathSeparator))
return pathParts[len(pathParts)-1]
/* the above may have edge conditions as it assumes the trailing value
* is a directory name. If errors are encountered, we _may_ need to use the
* inbuilt logic in the std lib and either check if the path indicated is a
* directory (appending slash) and then run:
base := filepath.Base(filepath.Dir(path))
if base == string(os.PathSeparator) || base == "." {
return "" // Consider it underivable: string zero value
}
return base
*/
}
// Write aka (save, serialize, marshal) the Function to disk at its path.
func (f Function) Write() (err error) {
path := filepath.Join(f.Root, FunctionFile)
var bb []byte
if bb, err = yaml.Marshal(&f); err != nil {
return
}
// TODO: open existing file for writing, such that existing permissions
// are preserved.
return ioutil.WriteFile(path, bb, 0644)
}
// Initialized returns if the Function has been initialized.
// Any errors are considered failure (invalid or inaccessible root, config file, etc).
func (f Function) Initialized() bool {
// Load the Function's configuration from disk and check if the (required) value Runtime is populated.
c, err := newConfig(f.Root)
if err != nil {
return false
}
return c.Runtime != "" && c.Name != ""
return !f.Created.IsZero()
}
// Built indicates the Function has been built. Does not guarantee the
// image indicated actually exists, just that it _should_ exist based off
// the current state of the Funciton object, in particular the value of
// the current state of the Function object, in particular the value of
// the Image and ImageDiget fields.
func (f Function) Built() bool {
// If Image (the override) and ImageDigest (the most recent build stamp) are
// both empty, the Function is considered unbuilt.
// TODO: upgrade to a "build complete" timestamp.
return f.Image != "" || f.ImageDigest != ""
}
// ImageWithDigest returns the full reference to the image including SHA256 Digest.
// If Digest is empty, image:tag is returned.
// TODO: Populate this only on a successful deploy, as this results on a dirty
// git tree on every build.
func (f Function) ImageWithDigest() string {
// Return image, if Digest is empty
if f.ImageDigest == "" {
@ -170,6 +198,8 @@ func (f Function) ImageWithDigest() string {
// example docker.io/alice/my.example.func:latest
// Default if not provided is --registry (a required global setting)
// followed by the provided (or derived) image name.
// TODO: this calculated field should probably be generated on instantiation
// to avoid confusion.
func DerivedImage(root, registry string) (image string, err error) {
f, err := NewFunction(root)
if err != nil {
@ -249,7 +279,7 @@ func assertEmptyRoot(path string) (err error) {
// contentiousFiles are files which, if extant, preclude the creation of a
// Function rooted in the given directory.
var contentiousFiles = []string{
ConfigFile,
FunctionFile,
}
// contentiousFilesIn the given directoy
@ -279,3 +309,94 @@ func isEffectivelyEmpty(dir string) (bool, error) {
}
return true, nil
}
// unmarshalFunction from disk (FunctionFile) using the passed Function as
// its defaults. If no serialized function exists at path, the Function
// returned is equivalent to the default passed.
func unmarshalFunction(f Function) (Function, error) {
var err error
var filename = filepath.Join(f.Root, FunctionFile)
// Return if there is no file to load, or if there is an error reading.
if _, err = os.Stat(filename); err != nil {
if os.IsNotExist(err) {
err = nil // missing file is not an error.
}
return f, err
}
// Load the file
bb, err := ioutil.ReadFile(filename)
if err != nil {
return f, err
}
// Unmarshal as yaml
if err = yaml.UnmarshalStrict(bb, &f); err != nil {
// Return immediately if there are syntactic errors.
return f, formatUnmarshalError(err)
}
return f, validateFunction(f)
}
// Validate Function is logically correct, returning a bundled, and quite
// verbose, formatted error detailing any issues.
func validateFunction(f Function) error {
var ctr int
errs := [][]string{
validateVolumes(f.Volumes),
ValidateBuildEnvs(f.BuildEnvs),
ValidateEnvs(f.Envs),
validateOptions(f.Options),
ValidateLabels(f.Labels),
}
var b strings.Builder
b.WriteString(fmt.Sprintf("'%v' contains errors:", FunctionFile))
for _, ee := range errs {
if len(ee) > 0 {
b.WriteString("\n") // Precede each group of errors with a linebreak
}
for _, e := range ee {
ctr++
b.WriteString("\t" + e)
}
}
if ctr == 0 {
return nil // Return nil if there were no validation errors.
}
return errors.New(b.String())
}
// Format yaml unmarshall error to be more human friendly.
func formatUnmarshalError(err error) error {
var (
e = err.Error()
rxp = regexp.MustCompile("not found in type .*")
header = fmt.Sprintf("'%v' is not valid:\n", FunctionFile)
)
if strings.HasPrefix(e, "yaml: unmarshal errors:") {
e = rxp.ReplaceAllString(e, "is not valid")
e = strings.Replace(e, "yaml: unmarshal errors:\n", header, 1)
} else if strings.HasPrefix(e, "yaml:") {
e = rxp.ReplaceAllString(e, "is not valid")
e = strings.Replace(e, "yaml: ", header+" ", 1)
}
return errors.New(e)
}
// Regex used during instantiation and validation of various Function fields
// by labels, envs, options, etc.
var (
regWholeSecret = regexp.MustCompile(`^{{\s*secret:((?:\w|['-]\w)+)\s*}}$`)
regKeyFromSecret = regexp.MustCompile(`^{{\s*secret:((?:\w|['-]\w)+):([-._a-zA-Z0-9]+)\s*}}$`)
regWholeConfigMap = regexp.MustCompile(`^{{\s*configMap:((?:\w|['-]\w)+)\s*}}$`)
regKeyFromConfigMap = regexp.MustCompile(`^{{\s*configMap:((?:\w|['-]\w)+):([-._a-zA-Z0-9]+)\s*}}$`)
regLocalEnv = regexp.MustCompile(`^{{\s*env:(\w+)\s*}}$`)
)

126
function_envs.go Normal file
View File

@ -0,0 +1,126 @@
package function
import (
"fmt"
"strings"
"knative.dev/kn-plugin-func/utils"
)
type Env struct {
Name *string `yaml:"name,omitempty" jsonschema:"pattern=^[-._a-zA-Z][-._a-zA-Z0-9]*$"`
Value *string `yaml:"value"`
}
func (e Env) String() string {
if e.Name == nil && e.Value != nil {
match := regWholeSecret.FindStringSubmatch(*e.Value)
if len(match) == 2 {
return fmt.Sprintf("All key=value pairs from Secret \"%s\"", match[1])
}
match = regWholeConfigMap.FindStringSubmatch(*e.Value)
if len(match) == 2 {
return fmt.Sprintf("All key=value pairs from ConfigMap \"%s\"", match[1])
}
} else if e.Name != nil && e.Value != nil {
match := regKeyFromSecret.FindStringSubmatch(*e.Value)
if len(match) == 3 {
return fmt.Sprintf("Env \"%s\" with value set from key \"%s\" from Secret \"%s\"", *e.Name, match[2], match[1])
}
match = regKeyFromConfigMap.FindStringSubmatch(*e.Value)
if len(match) == 3 {
return fmt.Sprintf("Env \"%s\" with value set from key \"%s\" from ConfigMap \"%s\"", *e.Name, match[2], match[1])
}
match = regLocalEnv.FindStringSubmatch(*e.Value)
if len(match) == 2 {
return fmt.Sprintf("Env \"%s\" with value set from local env variable \"%s\"", *e.Name, match[1])
}
return fmt.Sprintf("Env \"%s\" with value \"%s\"", *e.Name, *e.Value)
}
return ""
}
// ValidateEnvs checks that input Envs are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - name: EXAMPLE1 # ENV directly from a value
// value: value1
// - name: EXAMPLE2 # ENV from the local ENV var
// value: {{ env:MY_ENV }}
// - name: EXAMPLE3
// value: {{ secret:secretName:key }} # ENV from a key in secret
// - value: {{ secret:secretName }} # all key-pair values from secret are set as ENV
// - name: EXAMPLE4
// value: {{ configMap:configMapName:key }} # ENV from a key in configMap
// - value: {{ configMap:configMapName }} # all key-pair values from configMap are set as ENV
func ValidateEnvs(envs []Env) (errors []string) {
for i, env := range envs {
if env.Name == nil && env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is not properly set", i))
} else if env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is missing value field, only name '%s' is set", i, *env.Name))
} else if env.Name == nil {
// all key-pair values from secret are set as ENV; {{ secret:secretName }} or {{ configMap:configMapName }}
if !regWholeSecret.MatchString(*env.Value) && !regWholeConfigMap.MatchString(*env.Value) {
errors = append(errors, fmt.Sprintf("env entry #%d has invalid value field set, it has '%s', but allowed is only '{{ secret:secretName }}' or '{{ configMap:configMapName }}'",
i, *env.Value))
}
} else {
if err := utils.ValidateEnvVarName(*env.Name); err != nil {
errors = append(errors, fmt.Sprintf("env entry #%d has invalid name set: %q; %s", i, *env.Name, err.Error()))
}
if strings.HasPrefix(*env.Value, "{{") {
// ENV from the local ENV var; {{ env:MY_ENV }}
// or
// ENV from a key in secret/configMap; {{ secret:secretName:key }} or {{ configMap:configMapName:key }}
if !regLocalEnv.MatchString(*env.Value) && !regKeyFromSecret.MatchString(*env.Value) && !regKeyFromConfigMap.MatchString(*env.Value) {
errors = append(errors,
fmt.Sprintf(
"env entry #%d with name '%s' has invalid value field set, it has '%s', but allowed is only '{{ env:MY_ENV }}', '{{ secret:secretName:key }}' or '{{ configMap:configMapName:key }}'",
i, *env.Name, *env.Value))
}
}
}
}
return
}
// ValidateBuildEnvs checks that input BuildEnvs are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - name: EXAMPLE1 # ENV directly from a value
// value: value1
// - name: EXAMPLE2 # ENV from the local ENV var
// value: {{ env:MY_ENV }}
func ValidateBuildEnvs(envs []Env) (errors []string) {
for i, env := range envs {
if env.Name == nil && env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is not properly set", i))
} else if env.Value == nil {
errors = append(errors, fmt.Sprintf("env entry #%d is missing value field, only name '%s' is set", i, *env.Name))
} else {
if err := utils.ValidateEnvVarName(*env.Name); err != nil {
errors = append(errors, fmt.Sprintf("env entry #%d has invalid name set: %q; %s", i, *env.Name, err.Error()))
}
if strings.HasPrefix(*env.Value, "{{") {
// ENV from the local ENV var; {{ env:MY_ENV }}
if !regLocalEnv.MatchString(*env.Value) {
errors = append(errors,
fmt.Sprintf(
"env entry #%d with name '%s' has invalid value field set, it has '%s', but allowed is only '{{ env:MY_ENV }}'",
i, *env.Name, *env.Value))
}
}
}
}
return
}

611
function_envs_unit_test.go Normal file
View File

@ -0,0 +1,611 @@
//go:build !integration
// +build !integration
package function
import "testing"
func Test_validateBuildEnvs(t *testing.T) {
name := "name"
name2 := "name2"
value := "value"
value2 := "value2"
incorrectName := ",foo"
incorrectName2 := ":foo"
valueLocalEnv := "{{ env:MY_ENV }}"
valueLocalEnv2 := "{{ env:MY_ENV2 }}"
valueLocalEnv3 := "{{env:MY_ENV3}}"
valueLocalEnvIncorrect := "{{ envs:MY_ENV }}"
valueLocalEnvIncorrect2 := "{{ MY_ENV }}"
valueLocalEnvIncorrect3 := "{{env:MY_ENV}}foo"
tests := []struct {
name string
envs []Env
errs int
}{
{
"correct entry - single env with value",
[]Env{
{
Name: &name,
Value: &value,
},
},
0,
},
{
"incorrect entry - missing value",
[]Env{
{
Name: &name,
},
},
1,
},
{
"incorrect entry - invalid name",
[]Env{
{
Name: &incorrectName,
Value: &value,
},
},
1,
},
{
"incorrect entry - invalid name2",
[]Env{
{
Name: &incorrectName2,
Value: &value,
},
},
1,
},
{
"correct entry - multiple envs with value",
[]Env{
{
Name: &name,
Value: &value,
},
{
Name: &name2,
Value: &value2,
},
},
0,
},
{
"incorrect entry - mmissing value - multiple envs",
[]Env{
{
Name: &name,
},
{
Name: &name2,
},
},
2,
},
{
"correct entry - single env with value Local env",
[]Env{
{
Name: &name,
Value: &valueLocalEnv,
},
},
0,
},
{
"correct entry - multiple envs with value Local env",
[]Env{
{
Name: &name,
Value: &valueLocalEnv,
},
{
Name: &name,
Value: &valueLocalEnv2,
},
{
Name: &name,
Value: &valueLocalEnv3,
},
},
0,
},
{
"incorrect entry - multiple envs with value Local env",
[]Env{
{
Name: &name,
Value: &valueLocalEnv,
},
{
Name: &name,
Value: &valueLocalEnvIncorrect,
},
{
Name: &name,
Value: &valueLocalEnvIncorrect2,
},
{
Name: &name,
Value: &valueLocalEnvIncorrect3,
},
},
3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateEnvs(tt.envs); len(got) != tt.errs {
t.Errorf("validateEnvs() = %v\n got %d errors but want %d", got, len(got), tt.errs)
}
})
}
}
func Test_validateEnvs(t *testing.T) {
name := "name"
name2 := "name2"
value := "value"
value2 := "value2"
incorrectName := ",foo"
incorrectName2 := ":foo"
valueLocalEnv := "{{ env:MY_ENV }}"
valueLocalEnv2 := "{{ env:MY_ENV2 }}"
valueLocalEnv3 := "{{env:MY_ENV3}}"
valueLocalEnvIncorrect := "{{ envs:MY_ENV }}"
valueLocalEnvIncorrect2 := "{{ MY_ENV }}"
valueLocalEnvIncorrect3 := "{{env:MY_ENV}}foo"
valueSecretKey := "{{ secret:mysecret:key }}"
valueSecretKey2 := "{{secret:my-secret:key }}"
valueSecretKey3 := "{{secret:my-secret:key2}}"
valueSecretKey4 := "{{secret:my-secret:key-2}}"
valueSecretKey5 := "{{secret:my-secret:key.2}}"
valueSecretKey6 := "{{secret:my-secret:key_2}}"
valueSecretKey7 := "{{secret:my-secret:key_2-1}}"
valueSecretKey8 := "{{secret:my-secret:key_2-1.3}}"
valueSecretKeyIncorrect := "{{ secret:my-secret:key,key }}"
valueSecretKeyIncorrect2 := "{{ my-secret:key }}"
valueSecretKeyIncorrect3 := "{{ secret:my-secret:key }}foo"
valueConfigMapKey := "{{ configMap:myconfigmap:key }}"
valueConfigMapKey2 := "{{ configMap:myconfigmap:key }}"
valueConfigMapKey3 := "{{ configMap:myconfigmap:key2 }}"
valueConfigMapKey4 := "{{ configMap:myconfigmap:key-2 }}"
valueConfigMapKey5 := "{{ configMap:myconfigmap:key.2 }}"
valueConfigMapKey6 := "{{ configMap:myconfigmap:key_2 }}"
valueConfigMapKey7 := "{{ configMap:myconfigmap:key_2-1 }}"
valueConfigMapKey8 := "{{ configMap:myconfigmap:key_2.1 }}"
valueSecret := "{{ secret:my-secret }}"
valueSecret2 := "{{ secret:mysecret }}"
valueSecret3 := "{{ secret:mysecret}}"
valueSecretIncorrect := "{{ my-secret }}"
valueSecretIncorrect2 := "my-secret"
valueSecretIncorrect3 := "{{ secret:my-secret }}foo"
valueConfigMap := "{{ configMap:myconfigmap }}"
tests := []struct {
name string
envs []Env
errs int
}{
{
"correct entry - single env with value",
[]Env{
{
Name: &name,
Value: &value,
},
},
0,
},
{
"incorrect entry - missing value",
[]Env{
{
Name: &name,
},
},
1,
},
{
"incorrect entry - invalid name",
[]Env{
{
Name: &incorrectName,
Value: &value,
},
},
1,
},
{
"incorrect entry - invalid name2",
[]Env{
{
Name: &incorrectName2,
Value: &value,
},
},
1,
},
{
"correct entry - multiple envs with value",
[]Env{
{
Name: &name,
Value: &value,
},
{
Name: &name2,
Value: &value2,
},
},
0,
},
{
"incorrect entry - mmissing value - multiple envs",
[]Env{
{
Name: &name,
},
{
Name: &name2,
},
},
2,
},
{
"correct entry - single env with value Local env",
[]Env{
{
Name: &name,
Value: &valueLocalEnv,
},
},
0,
},
{
"correct entry - multiple envs with value Local env",
[]Env{
{
Name: &name,
Value: &valueLocalEnv,
},
{
Name: &name,
Value: &valueLocalEnv2,
},
{
Name: &name,
Value: &valueLocalEnv3,
},
},
0,
},
{
"incorrect entry - multiple envs with value Local env",
[]Env{
{
Name: &name,
Value: &valueLocalEnv,
},
{
Name: &name,
Value: &valueLocalEnvIncorrect,
},
{
Name: &name,
Value: &valueLocalEnvIncorrect2,
},
{
Name: &name,
Value: &valueLocalEnvIncorrect3,
},
},
3,
},
{
"correct entry - single secret with key",
[]Env{
{
Name: &name,
Value: &valueSecretKey,
},
},
0,
},
{
"correct entry - single configMap with key",
[]Env{
{
Name: &name,
Value: &valueConfigMapKey,
},
},
0,
},
{
"correct entry - multiple configMaps with key",
[]Env{
{
Name: &name,
Value: &valueConfigMapKey,
},
{
Name: &name,
Value: &valueConfigMapKey2,
},
{
Name: &name,
Value: &valueConfigMapKey3,
},
{
Name: &name,
Value: &valueConfigMapKey4,
},
{
Name: &name,
Value: &valueConfigMapKey5,
},
{
Name: &name,
Value: &valueConfigMapKey6,
},
{
Name: &name,
Value: &valueConfigMapKey7,
},
{
Name: &name,
Value: &valueConfigMapKey8,
},
},
0,
},
{
"correct entry - multiple secrets with key",
[]Env{
{
Name: &name,
Value: &valueSecretKey,
},
{
Name: &name,
Value: &valueSecretKey2,
},
{
Name: &name,
Value: &valueSecretKey3,
},
{
Name: &name,
Value: &valueSecretKey4,
},
{
Name: &name,
Value: &valueSecretKey5,
},
{
Name: &name,
Value: &valueSecretKey6,
},
{
Name: &name,
Value: &valueSecretKey7,
},
{
Name: &name,
Value: &valueSecretKey8,
},
},
0,
},
{
"correct entry - both secret and configmap with key",
[]Env{
{
Name: &name,
Value: &valueSecretKey,
},
{
Name: &name,
Value: &valueConfigMapKey,
},
},
0,
},
{
"incorrect entry - single secret with key",
[]Env{
{
Name: &name,
Value: &valueSecretKeyIncorrect,
},
},
1,
},
{
"incorrect entry - multiple secrets with key",
[]Env{
{
Name: &name,
Value: &valueSecretKey,
},
{
Name: &name,
Value: &valueSecretKeyIncorrect,
},
{
Name: &name,
Value: &valueSecretKeyIncorrect2,
},
{
Name: &name,
Value: &valueSecretKeyIncorrect3,
},
},
3,
},
{
"correct entry - single whole secret",
[]Env{
{
Value: &valueSecret,
},
},
0,
},
{
"correct entry - single whole configMap",
[]Env{
{
Value: &valueConfigMap,
},
},
0,
},
{
"correct entry - multiple whole secret",
[]Env{
{
Value: &valueSecret,
},
{
Value: &valueSecret2,
},
{
Value: &valueSecret3,
},
},
0,
},
{
"correct entry - both whole secret and configMap",
[]Env{
{
Value: &valueSecret,
},
{
Value: &valueConfigMap,
},
},
0,
},
{
"incorrect entry - single whole secret",
[]Env{
{
Value: &value,
},
},
1,
},
{
"incorrect entry - multiple whole secret",
[]Env{
{
Value: &valueSecretIncorrect,
},
{
Value: &valueSecretIncorrect2,
},
{
Value: &valueSecretIncorrect3,
},
{
Value: &value,
},
{
Value: &valueLocalEnv,
},
{
Value: &valueLocalEnv2,
},
{
Value: &valueLocalEnv3,
},
{
Value: &valueSecret,
},
},
7,
},
{
"correct entry - all combinations",
[]Env{
{
Name: &name,
Value: &value,
},
{
Name: &name2,
Value: &value2,
},
{
Name: &name,
Value: &valueLocalEnv,
},
{
Name: &name,
Value: &valueLocalEnv2,
},
{
Name: &name,
Value: &valueLocalEnv3,
},
{
Value: &valueSecret,
},
{
Value: &valueSecret2,
},
{
Value: &valueSecret3,
},
{
Value: &valueConfigMap,
},
{
Name: &name,
Value: &valueSecretKey,
},
{
Name: &name,
Value: &valueSecretKey2,
},
{
Name: &name,
Value: &valueSecretKey3,
},
{
Name: &name,
Value: &valueConfigMapKey,
},
},
0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidateEnvs(tt.envs); len(got) != tt.errs {
t.Errorf("validateEnvs() = %v\n got %d errors but want %d", got, len(got), tt.errs)
}
})
}
}

75
function_labels.go Normal file
View File

@ -0,0 +1,75 @@
package function
import (
"fmt"
"os"
"strings"
"knative.dev/kn-plugin-func/utils"
)
type Label struct {
// Key consist of optional prefix part (ended by '/') and name part
// Prefix part validation pattern: [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
// Name part validation pattern: ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]
Key *string `yaml:"key" jsonschema:"pattern=^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\\/)?([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$"`
Value *string `yaml:"value,omitempty" jsonschema:"pattern=^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$"`
}
func (l Label) String() string {
if l.Key != nil && l.Value == nil {
return fmt.Sprintf("Label with key \"%s\"", *l.Key)
} else if l.Key != nil && l.Value != nil {
match := regLocalEnv.FindStringSubmatch(*l.Value)
if len(match) == 2 {
return fmt.Sprintf("Label with key \"%s\" and value set from local env variable \"%s\"", *l.Key, match[1])
}
return fmt.Sprintf("Label with key \"%s\" and value \"%s\"", *l.Key, *l.Value)
}
return ""
}
// ValidateLabels checks that input labels are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - key: EXAMPLE1 # label directly from a value
// value: value1
// - key: EXAMPLE2 # label from the local ENV var
// value: {{ env:MY_ENV }}
func ValidateLabels(labels []Label) (errors []string) {
for i, label := range labels {
if label.Key == nil && label.Value == nil {
errors = append(errors, fmt.Sprintf("label entry #%d is not properly set", i))
} else if label.Key == nil && label.Value != nil {
errors = append(errors, fmt.Sprintf("label entry #%d is missing key field, only value '%s' is set", i, *label.Value))
} else {
if err := utils.ValidateLabelKey(*label.Key); err != nil {
errors = append(errors, fmt.Sprintf("label entry #%d has invalid key set: %q; %s", i, *label.Key, err.Error()))
}
if label.Value != nil {
if err := utils.ValidateLabelValue(*label.Value); err != nil {
errors = append(errors, fmt.Sprintf("label entry #%d has invalid value set: %q; %s", i, *label.Value, err.Error()))
}
if strings.HasPrefix(*label.Value, "{{") {
// ENV from the local ENV var; {{ env:MY_ENV }}
if !regLocalEnv.MatchString(*label.Value) {
errors = append(errors,
fmt.Sprintf(
"label entry #%d with key '%s' has invalid value field set, it has '%s', but allowed is only '{{ env:MY_ENV }}'",
i, *label.Key, *label.Value))
} else {
match := regLocalEnv.FindStringSubmatch(*label.Value)
value := os.Getenv(match[1])
if err := utils.ValidateLabelValue(value); err != nil {
errors = append(errors, fmt.Sprintf("label entry #%d with key '%s' has invalid value when the environment is evaluated: '%s': %s", i, *label.Key, value, err.Error()))
}
}
}
}
}
}
return
}

View File

@ -0,0 +1,245 @@
//go:build !integration
// +build !integration
package function
import (
"os"
"testing"
)
func Test_validateLabels(t *testing.T) {
key := "name"
key2 := "name-two"
key3 := "prefix.io/name3"
value := "value"
value2 := "value2"
value3 := "value3"
incorrectKey := ",foo"
incorrectKey2 := ":foo"
incorrectValue := ":foo"
valueLocalEnv := "{{ env:MY_ENV }}"
valueLocalEnv2 := "{{ env:MY_ENV2 }}"
valueLocalEnv3 := "{{env:MY_ENV3}}"
valueLocalEnvIncorrect := "{{ envs:MY_ENV }}"
valueLocalEnvIncorrect2 := "{{ MY_ENV }}"
valueLocalEnvIncorrect3 := "{{env:MY_ENV}}foo"
os.Setenv("BAD_EXAMPLE", ":invalid")
valueLocalEnvIncorrect4 := "{{env:BAD_EXAMPLE}}"
os.Setenv("GOOD_EXAMPLE", "valid")
valueLocalEnv4 := "{{env:GOOD_EXAMPLE}}"
tests := []struct {
key string
labels []Label
errs int
}{
{
"correct entry - single label with value",
[]Label{
{
Key: &key,
Value: &value,
},
},
0,
},
{
"correct entry - prefixed label with value",
[]Label{
{
Key: &key3,
Value: &value3,
},
},
0,
}, {
"incorrect entry - missing key",
[]Label{
{
Value: &value,
},
},
1,
}, {
"incorrect entry - missing multiple keys",
[]Label{
{
Value: &value,
},
{
Value: &value2,
},
},
2,
},
{
"incorrect entry - invalid key",
[]Label{
{
Key: &incorrectKey,
Value: &value,
},
},
1,
},
{
"incorrect entry - invalid key2",
[]Label{
{
Key: &incorrectKey2,
Value: &value,
},
},
1,
},
{
"incorrect entry - invalid value",
[]Label{
{
Key: &key,
Value: &incorrectValue,
},
},
1,
},
{
"correct entry - multiple labels with value",
[]Label{
{
Key: &key,
Value: &value,
},
{
Key: &key2,
Value: &value2,
},
},
0,
},
{
"correct entry - missing value - multiple labels",
[]Label{
{
Key: &key,
},
{
Key: &key2,
},
},
0,
},
{
"correct entry - single label with value from local env",
[]Label{
{
Key: &key,
Value: &valueLocalEnv,
},
},
0,
},
{
"correct entry - multiple labels with values from Local env",
[]Label{
{
Key: &key,
Value: &valueLocalEnv,
},
{
Key: &key,
Value: &valueLocalEnv2,
},
{
Key: &key,
Value: &valueLocalEnv3,
},
},
0,
},
{
"incorrect entry - multiple labels with values from Local env",
[]Label{
{
Key: &key,
Value: &valueLocalEnv,
},
{
Key: &key,
Value: &valueLocalEnvIncorrect,
},
{
Key: &key,
Value: &valueLocalEnvIncorrect2,
},
{
Key: &key,
Value: &valueLocalEnvIncorrect3,
},
},
3,
},
{
"correct entry - good environment variable value",
[]Label{
{
Key: &key,
Value: &valueLocalEnv4,
},
},
0,
}, {
"incorrect entry - bad environment variable value",
[]Label{
{
Key: &key,
Value: &valueLocalEnvIncorrect4,
},
},
1,
},
{
"correct entry - all combinations",
[]Label{
{
Key: &key,
Value: &value,
},
{
Key: &key2,
Value: &value2,
},
{
Key: &key3,
Value: &value3,
},
{
Key: &key,
Value: &valueLocalEnv,
},
{
Key: &key,
Value: &valueLocalEnv2,
},
{
Key: &key,
Value: &valueLocalEnv3,
},
},
0,
},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
if got := ValidateLabels(tt.labels); len(got) != tt.errs {
t.Errorf("validateLabels() = %v\n got %d errors but want %d", got, len(got), tt.errs)
}
})
}
}

139
function_options.go Normal file
View File

@ -0,0 +1,139 @@
package function
import (
"fmt"
"k8s.io/apimachinery/pkg/api/resource"
)
type Options struct {
Scale *ScaleOptions `yaml:"scale,omitempty"`
Resources *ResourcesOptions `yaml:"resources,omitempty"`
}
type ScaleOptions struct {
Min *int64 `yaml:"min,omitempty" jsonschema_extras:"minimum=0"`
Max *int64 `yaml:"max,omitempty" jsonschema_extras:"minimum=0"`
Metric *string `yaml:"metric,omitempty" jsonschema:"enum=concurrency,enum=rps"`
Target *float64 `yaml:"target,omitempty" jsonschema_extras:"minimum=0.01"`
Utilization *float64 `yaml:"utilization,omitempty" jsonschema:"minimum=1,maximum=100"`
}
type ResourcesOptions struct {
Requests *ResourcesRequestsOptions `yaml:"requests,omitempty"`
Limits *ResourcesLimitsOptions `yaml:"limits,omitempty"`
}
type ResourcesLimitsOptions struct {
CPU *string `yaml:"cpu,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
Memory *string `yaml:"memory,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
Concurrency *int64 `yaml:"concurrency,omitempty" jsonschema_extras:"minimum=0"`
}
type ResourcesRequestsOptions struct {
CPU *string `yaml:"cpu,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
Memory *string `yaml:"memory,omitempty" jsonschema:"pattern=^([+-]?[0-9.]+)([eEinumkKMGTP]*[-+]?[0-9]*)$"`
}
// validateOptions checks that input Options are correctly set.
// Returns array of error messages, empty if no errors are found
func validateOptions(options Options) (errors []string) {
// options.scale
if options.Scale != nil {
if options.Scale.Min != nil {
if *options.Scale.Min < 0 {
errors = append(errors, fmt.Sprintf("options field \"scale.min\" has invalid value set: %d, the value must be greater than \"0\"",
*options.Scale.Min))
}
}
if options.Scale.Max != nil {
if *options.Scale.Max < 0 {
errors = append(errors, fmt.Sprintf("options field \"scale.max\" has invalid value set: %d, the value must be greater than \"0\"",
*options.Scale.Max))
}
}
if options.Scale.Min != nil && options.Scale.Max != nil {
if *options.Scale.Max < *options.Scale.Min {
errors = append(errors, "options field \"scale.max\" value must be greater or equal to \"scale.min\"")
}
}
if options.Scale.Metric != nil {
if *options.Scale.Metric != "concurrency" && *options.Scale.Metric != "rps" {
errors = append(errors, fmt.Sprintf("options field \"scale.metric\" has invalid value set: %s, allowed is only \"concurrency\" or \"rps\"",
*options.Scale.Metric))
}
}
if options.Scale.Target != nil {
if *options.Scale.Target < 0.01 {
errors = append(errors, fmt.Sprintf("options field \"scale.target\" has value set to \"%f\", but it must not be less than 0.01",
*options.Scale.Target))
}
}
if options.Scale.Utilization != nil {
if *options.Scale.Utilization < 1 || *options.Scale.Utilization > 100 {
errors = append(errors,
fmt.Sprintf("options field \"scale.utilization\" has value set to \"%f\", but it must not be less than 1 or greater than 100",
*options.Scale.Utilization))
}
}
}
// options.resource
if options.Resources != nil {
// options.resource.requests
if options.Resources.Requests != nil {
if options.Resources.Requests.CPU != nil {
_, err := resource.ParseQuantity(*options.Resources.Requests.CPU)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.requests.cpu\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Requests.CPU, err.Error()))
}
}
if options.Resources.Requests.Memory != nil {
_, err := resource.ParseQuantity(*options.Resources.Requests.Memory)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.requests.memory\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Requests.Memory, err.Error()))
}
}
}
// options.resource.limits
if options.Resources.Limits != nil {
if options.Resources.Limits.CPU != nil {
_, err := resource.ParseQuantity(*options.Resources.Limits.CPU)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.limits.cpu\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Limits.CPU, err.Error()))
}
}
if options.Resources.Limits.Memory != nil {
_, err := resource.ParseQuantity(*options.Resources.Limits.Memory)
if err != nil {
errors = append(errors, fmt.Sprintf("options field \"resources.limits.memory\" has invalid value set: \"%s\"; \"%s\"",
*options.Resources.Limits.Memory, err.Error()))
}
}
if options.Resources.Limits.Concurrency != nil {
if *options.Resources.Limits.Concurrency < 0 {
errors = append(errors, fmt.Sprintf("options field \"resources.limits.concurrency\" has value set to \"%d\", but it must not be less than 0",
*options.Resources.Limits.Concurrency))
}
}
}
}
return
}

View File

@ -0,0 +1,326 @@
//go:build !integration
// +build !integration
package function
import (
"testing"
"knative.dev/pkg/ptr"
)
func Test_validateOptions(t *testing.T) {
tests := []struct {
name string
options Options
errs int
}{
{
"correct 'scale.metric' - concurrency",
Options{
Scale: &ScaleOptions{
Metric: ptr.String("concurrency"),
},
},
0,
},
{
"correct 'scale.metric' - rps",
Options{
Scale: &ScaleOptions{
Metric: ptr.String("rps"),
},
},
0,
},
{
"incorrect 'scale.metric'",
Options{
Scale: &ScaleOptions{
Metric: ptr.String("foo"),
},
},
1,
},
{
"correct 'scale.min'",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(1),
},
},
0,
},
{
"correct 'scale.max'",
Options{
Scale: &ScaleOptions{
Max: ptr.Int64(10),
},
},
0,
},
{
"correct 'scale.min' & 'scale.max'",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(0),
Max: ptr.Int64(10),
},
},
0,
},
{
"incorrect 'scale.min' & 'scale.max'",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(100),
Max: ptr.Int64(10),
},
},
1,
},
{
"incorrect 'scale.min' - negative value",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(-10),
},
},
1,
},
{
"incorrect 'scale.max' - negative value",
Options{
Scale: &ScaleOptions{
Max: ptr.Int64(-10),
},
},
1,
},
{
"correct 'scale.target'",
Options{
Scale: &ScaleOptions{
Target: ptr.Float64(50),
},
},
0,
},
{
"incorrect 'scale.target'",
Options{
Scale: &ScaleOptions{
Target: ptr.Float64(0),
},
},
1,
},
{
"correct 'scale.utilization'",
Options{
Scale: &ScaleOptions{
Utilization: ptr.Float64(50),
},
},
0,
},
{
"incorrect 'scale.utilization' - < 1",
Options{
Scale: &ScaleOptions{
Utilization: ptr.Float64(0),
},
},
1,
},
{
"incorrect 'scale.utilization' - > 100",
Options{
Scale: &ScaleOptions{
Utilization: ptr.Float64(110),
},
},
1,
},
{
"correct 'resources.requests.cpu'",
Options{
Resources: &ResourcesOptions{
Requests: &ResourcesRequestsOptions{
CPU: ptr.String("1000m"),
},
},
},
0,
},
{
"incorrect 'resources.requests.cpu'",
Options{
Resources: &ResourcesOptions{
Requests: &ResourcesRequestsOptions{
CPU: ptr.String("foo"),
},
},
},
1,
},
{
"correct 'resources.requests.memory'",
Options{
Resources: &ResourcesOptions{
Requests: &ResourcesRequestsOptions{
Memory: ptr.String("100Mi"),
},
},
},
0,
},
{
"incorrect 'resources.requests.memory'",
Options{
Resources: &ResourcesOptions{
Requests: &ResourcesRequestsOptions{
Memory: ptr.String("foo"),
},
},
},
1,
},
{
"correct 'resources.limits.cpu'",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
CPU: ptr.String("1000m"),
},
},
},
0,
},
{
"incorrect 'resources.limits.cpu'",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
CPU: ptr.String("foo"),
},
},
},
1,
},
{
"correct 'resources.limits.memory'",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
Memory: ptr.String("100Mi"),
},
},
},
0,
},
{
"incorrect 'resources.limits.memory'",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
Memory: ptr.String("foo"),
},
},
},
1,
},
{
"correct 'resources.limits.concurrency'",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
Concurrency: ptr.Int64(50),
},
},
},
0,
},
{
"correct 'resources.limits.concurrency' - 0",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
Concurrency: ptr.Int64(0),
},
},
},
0,
},
{
"incorrect 'resources.limits.concurrency' - negative value",
Options{
Resources: &ResourcesOptions{
Limits: &ResourcesLimitsOptions{
Concurrency: ptr.Int64(-10),
},
},
},
1,
},
{
"correct all options",
Options{
Resources: &ResourcesOptions{
Requests: &ResourcesRequestsOptions{
CPU: ptr.String("1000m"),
Memory: ptr.String("100Mi"),
},
Limits: &ResourcesLimitsOptions{
CPU: ptr.String("1000m"),
Memory: ptr.String("100Mi"),
Concurrency: ptr.Int64(10),
},
},
Scale: &ScaleOptions{
Min: ptr.Int64(0),
Max: ptr.Int64(10),
Metric: ptr.String("concurrency"),
Target: ptr.Float64(40.5),
Utilization: ptr.Float64(35.5),
},
},
0,
},
{
"incorrect all options",
Options{
Resources: &ResourcesOptions{
Requests: &ResourcesRequestsOptions{
CPU: ptr.String("foo"),
Memory: ptr.String("foo"),
},
Limits: &ResourcesLimitsOptions{
CPU: ptr.String("foo"),
Memory: ptr.String("foo"),
Concurrency: ptr.Int64(-1),
},
},
Scale: &ScaleOptions{
Min: ptr.Int64(-1),
Max: ptr.Int64(-1),
Metric: ptr.String("foo"),
Target: ptr.Float64(-1),
Utilization: ptr.Float64(110),
},
},
10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validateOptions(tt.options); len(got) != tt.errs {
t.Errorf("validateOptions() = %v\n got %d errors but want %d", got, len(got), tt.errs)
}
})
}
}

View File

@ -10,137 +10,15 @@ import (
. "knative.dev/kn-plugin-func/testing"
)
func TestFunction_ImageWithDigest(t *testing.T) {
type fields struct {
Image string
ImageDigest string
}
tests := []struct {
name string
fields fields
want string
}{
{
name: "Full path with port",
fields: fields{Image: "image-registry.openshift-image-registry.svc.cluster.local:5000/default/bar", ImageDigest: "42"},
want: "image-registry.openshift-image-registry.svc.cluster.local:5000/default/bar@42",
},
{
name: "Path with namespace",
fields: fields{Image: "johndoe/bar", ImageDigest: "42"},
want: "johndoe/bar@42",
},
{
name: "Just image name",
fields: fields{Image: "bar:latest", ImageDigest: "42"},
want: "bar@42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := fn.Function{
Image: tt.fields.Image,
ImageDigest: tt.fields.ImageDigest,
}
if got := f.ImageWithDigest(); got != tt.want {
t.Errorf("ImageWithDigest() = %v, want %v", got, tt.want)
}
})
}
}
func Test_DerivedImage(t *testing.T) {
tests := []struct {
name string
fnName string
image string
registry string
want string
}{
{
name: "No change",
fnName: "testDerivedImage",
image: "docker.io/foo/testDerivedImage:latest",
registry: "docker.io/foo",
want: "docker.io/foo/testDerivedImage:latest",
},
{
name: "Same registry without docker.io/, original with docker.io/",
fnName: "testDerivedImage0",
image: "docker.io/foo/testDerivedImage0:latest",
registry: "foo",
want: "docker.io/foo/testDerivedImage0:latest",
},
{
name: "Same registry, original without docker.io/",
fnName: "testDerivedImage1",
image: "foo/testDerivedImage1:latest",
registry: "foo",
want: "docker.io/foo/testDerivedImage1:latest",
},
{
name: "Different registry without docker.io/, original without docker.io/",
fnName: "testDerivedImage2",
image: "foo/testDerivedImage2:latest",
registry: "bar",
want: "docker.io/bar/testDerivedImage2:latest",
},
{
name: "Different registry with docker.io/, original without docker.io/",
fnName: "testDerivedImage3",
image: "foo/testDerivedImage3:latest",
registry: "docker.io/bar",
want: "docker.io/bar/testDerivedImage3:latest",
},
{
name: "Different registry with docker.io/, original with docker.io/",
fnName: "testDerivedImage4",
image: "docker.io/foo/testDerivedImage4:latest",
registry: "docker.io/bar",
want: "docker.io/bar/testDerivedImage4:latest",
},
{
name: "Different registry with quay.io/, original without docker.io/",
fnName: "testDerivedImage5",
image: "foo/testDerivedImage5:latest",
registry: "quay.io/foo",
want: "quay.io/foo/testDerivedImage5:latest",
},
{
name: "Different registry with quay.io/, original with docker.io/",
fnName: "testDerivedImage6",
image: "docker.io/foo/testDerivedImage6:latest",
registry: "quay.io/foo",
want: "quay.io/foo/testDerivedImage6:latest",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := "testdata/" + tt.fnName
defer Using(t, root)()
f := fn.Function{
Name: tt.fnName,
Root: root,
Image: tt.image,
}
// write out the function
client := fn.New()
err := client.Create(f)
if err != nil {
t.Fatal(err)
}
got, err := fn.DerivedImage(f.Root, tt.registry)
if err != nil {
t.Errorf("DerivedImage() for image %v and registry %v; got error %v", tt.image, tt.registry, err)
}
if got != tt.want {
t.Errorf("DerivedImage() for image %v and registry %v; got %v, want %v", tt.image, tt.registry, got, tt.want)
}
})
// TestFunctionNameDefault ensures that a Function's name is defaulted to that
// which can be derived from the last part of its path.
func TestFunctionNameDefault(t *testing.T) {
root := "testdata/testFunctionNameDefault"
defer Using(t, root)()
_, err := fn.NewFunction(root)
if err != nil {
t.Fatal(err)
}
// TODO
// Test that the name was defaulted
}

144
function_unit_test.go Normal file
View File

@ -0,0 +1,144 @@
//go:build !integration
// +build !integration
package function
import (
"testing"
. "knative.dev/kn-plugin-func/testing"
)
func TestFunction_ImageWithDigest(t *testing.T) {
type fields struct {
Image string
ImageDigest string
}
tests := []struct {
name string
fields fields
want string
}{
{
name: "Full path with port",
fields: fields{Image: "image-registry.openshift-image-registry.svc.cluster.local:5000/default/bar", ImageDigest: "42"},
want: "image-registry.openshift-image-registry.svc.cluster.local:5000/default/bar@42",
},
{
name: "Path with namespace",
fields: fields{Image: "johndoe/bar", ImageDigest: "42"},
want: "johndoe/bar@42",
},
{
name: "Just image name",
fields: fields{Image: "bar:latest", ImageDigest: "42"},
want: "bar@42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := Function{
Image: tt.fields.Image,
ImageDigest: tt.fields.ImageDigest,
}
if got := f.ImageWithDigest(); got != tt.want {
t.Errorf("ImageWithDigest() = %v, want %v", got, tt.want)
}
})
}
}
func Test_DerivedImage(t *testing.T) {
tests := []struct {
name string
fnName string
image string
registry string
want string
}{
{
name: "No change",
fnName: "testDerivedImage",
image: "docker.io/foo/testDerivedImage:latest",
registry: "docker.io/foo",
want: "docker.io/foo/testDerivedImage:latest",
},
{
name: "Same registry without docker.io/, original with docker.io/",
fnName: "testDerivedImage0",
image: "docker.io/foo/testDerivedImage0:latest",
registry: "foo",
want: "docker.io/foo/testDerivedImage0:latest",
},
{
name: "Same registry, original without docker.io/",
fnName: "testDerivedImage1",
image: "foo/testDerivedImage1:latest",
registry: "foo",
want: "docker.io/foo/testDerivedImage1:latest",
},
{
name: "Different registry without docker.io/, original without docker.io/",
fnName: "testDerivedImage2",
image: "foo/testDerivedImage2:latest",
registry: "bar",
want: "docker.io/bar/testDerivedImage2:latest",
},
{
name: "Different registry with docker.io/, original without docker.io/",
fnName: "testDerivedImage3",
image: "foo/testDerivedImage3:latest",
registry: "docker.io/bar",
want: "docker.io/bar/testDerivedImage3:latest",
},
{
name: "Different registry with docker.io/, original with docker.io/",
fnName: "testDerivedImage4",
image: "docker.io/foo/testDerivedImage4:latest",
registry: "docker.io/bar",
want: "docker.io/bar/testDerivedImage4:latest",
},
{
name: "Different registry with quay.io/, original without docker.io/",
fnName: "testDerivedImage5",
image: "foo/testDerivedImage5:latest",
registry: "quay.io/foo",
want: "quay.io/foo/testDerivedImage5:latest",
},
{
name: "Different registry with quay.io/, original with docker.io/",
fnName: "testDerivedImage6",
image: "docker.io/foo/testDerivedImage6:latest",
registry: "quay.io/foo",
want: "quay.io/foo/testDerivedImage6:latest",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := "testdata/" + tt.fnName
defer Using(t, root)()
f := Function{
Name: tt.fnName,
Root: root,
Image: tt.image,
}
// write out the function
client := New()
err := client.Create(f)
if err != nil {
t.Fatal(err)
}
got, err := DerivedImage(f.Root, tt.registry)
if err != nil {
t.Errorf("DerivedImage() for image %v and registry %v; got error %v", tt.image, tt.registry, err)
}
if got != tt.want {
t.Errorf("DerivedImage() for image %v and registry %v; got %v, want %v", tt.image, tt.registry, got, tt.want)
}
})
}
}

49
function_volumes.go Normal file
View File

@ -0,0 +1,49 @@
package function
import "fmt"
type Volume struct {
Secret *string `yaml:"secret,omitempty" jsonschema:"oneof_required=secret"`
ConfigMap *string `yaml:"configMap,omitempty" jsonschema:"oneof_required=configmap"`
Path *string `yaml:"path"`
}
func (v Volume) String() string {
if v.ConfigMap != nil {
return fmt.Sprintf("ConfigMap \"%s\" mounted at path: \"%s\"", *v.ConfigMap, *v.Path)
} else if v.Secret != nil {
return fmt.Sprintf("Secret \"%s\" mounted at path: \"%s\"", *v.Secret, *v.Path)
}
return ""
}
// validateVolumes checks that input Volumes are correct and contain all necessary fields.
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - secret: example-secret # mount Secret as Volume
// path: /etc/secret-volume
// - configMap: example-configMap # mount ConfigMap as Volume
// path: /etc/configMap-volume
func validateVolumes(volumes []Volume) (errors []string) {
for i, vol := range volumes {
if vol.Secret != nil && vol.ConfigMap != nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is not properly set, both secret '%s' and configMap '%s' can not be set at the same time",
i, *vol.Secret, *vol.ConfigMap))
} else if vol.Path == nil && vol.Secret == nil && vol.ConfigMap == nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is not properly set", i))
} else if vol.Path == nil {
if vol.Secret != nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is missing path field, only secret '%s' is set", i, *vol.Secret))
} else if vol.ConfigMap != nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is missing path field, only configMap '%s' is set", i, *vol.ConfigMap))
}
} else if vol.Path != nil && vol.Secret == nil && vol.ConfigMap == nil {
errors = append(errors, fmt.Sprintf("volume entry #%d is missing secret or configMap field, only path '%s' is set", i, *vol.Path))
}
}
return
}

View File

@ -0,0 +1,141 @@
//go:build !integration
// +build !integration
package function
import "testing"
func Test_validateVolumes(t *testing.T) {
secret := "secret"
path := "path"
secret2 := "secret2"
path2 := "path2"
cm := "configMap"
tests := []struct {
name string
volumes []Volume
errs int
}{
{
"correct entry - single volume with secret",
[]Volume{
{
Secret: &secret,
Path: &path,
},
},
0,
},
{
"correct entry - single volume with configmap",
[]Volume{
{
ConfigMap: &cm,
Path: &path,
},
},
0,
},
{
"correct entry - multiple volumes with secrets",
[]Volume{
{
Secret: &secret,
Path: &path,
},
{
Secret: &secret2,
Path: &path2,
},
},
0,
},
{
"correct entry - multiple volumes with both secret and configMap",
[]Volume{
{
Secret: &secret,
Path: &path,
},
{
ConfigMap: &cm,
Path: &path2,
},
},
0,
},
{
"missing secret/configMap - single volume",
[]Volume{
{
Path: &path,
},
},
1,
},
{
"missing path - single volume with secret",
[]Volume{
{
Secret: &secret,
},
},
1,
},
{
"missing path - single volume with configMap",
[]Volume{
{
ConfigMap: &cm,
},
},
1,
},
{
"missing secret/configMap and path - single volume",
[]Volume{
{},
},
1,
},
{
"missing secret/configMap in one volume - multiple volumes",
[]Volume{
{
Secret: &secret,
Path: &path,
},
{
Path: &path2,
},
},
1,
},
{
"missing secret/configMap and path in two different volumes - multiple volumes",
[]Volume{
{
Secret: &secret,
Path: &path,
},
{
Secret: &secret,
},
{
Path: &path2,
},
},
2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validateVolumes(tt.volumes); len(got) != tt.errs {
t.Errorf("validateVolumes() = %v\n got %d errors but want %d", got, len(got), tt.errs)
}
})
}
}

View File

@ -1,25 +1,42 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"$ref": "#/definitions/Config",
"$ref": "#/definitions/Function",
"definitions": {
"Config": {
"Env": {
"required": [
"value"
],
"properties": {
"name": {
"pattern": "^[-._a-zA-Z][-._a-zA-Z0-9]*$",
"type": "string"
},
"value": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object"
},
"Function": {
"required": [
"name",
"namespace",
"runtime",
"Registry",
"image",
"imageDigest",
"builder",
"builders",
"buildpacks",
"healthEndpoints",
"volumes",
"buildEnvs",
"envs",
"annotations",
"options",
"labels",
"created"
"healthEndpoints",
"Created"
],
"properties": {
"name": {
@ -32,6 +49,9 @@
"runtime": {
"type": "string"
},
"Registry": {
"type": "string"
},
"image": {
"type": "string"
},
@ -55,10 +75,6 @@
},
"type": "array"
},
"healthEndpoints": {
"$schema": "http://json-schema.org/draft-04/schema#",
"$ref": "#/definitions/HealthEndpoints"
},
"volumes": {
"items": {
"$schema": "http://json-schema.org/draft-04/schema#",
@ -98,7 +114,11 @@
},
"type": "array"
},
"created": {
"healthEndpoints": {
"$schema": "http://json-schema.org/draft-04/schema#",
"$ref": "#/definitions/HealthEndpoints"
},
"Created": {
"type": "string",
"format": "date-time"
}
@ -106,22 +126,6 @@
"additionalProperties": false,
"type": "object"
},
"Env": {
"required": [
"value"
],
"properties": {
"name": {
"pattern": "^[-._a-zA-Z][-._a-zA-Z0-9]*$",
"type": "string"
},
"value": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object"
},
"HealthEndpoints": {
"properties": {
"liveness": {

View File

@ -23,7 +23,7 @@ func main() {
// Genereated schema is written into schema/func_yaml-schema.json file
func generateFuncYamlSchema() error {
// generate json schema for Function struct
js := jsonschema.Reflect(&fn.Config{})
js := jsonschema.Reflect(&fn.Function{})
schema, err := js.MarshalJSON()
if err != nil {
return err