Files
fission-src/pkg/fission-cli/cmd/spec/spec.go
T

635 lines
20 KiB
Go

/*
Copyright 2019 The Fission Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package spec
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/ghodss/yaml"
multierror "github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
fv1 "github.com/fission/fission/pkg/apis/fission.io/v1"
"github.com/fission/fission/pkg/fission-cli/cliwrapper/cli"
"github.com/fission/fission/pkg/fission-cli/cmd/spec/types"
"github.com/fission/fission/pkg/fission-cli/console"
"github.com/fission/fission/pkg/fission-cli/util"
"github.com/fission/fission/pkg/generator/encoder"
v1generator "github.com/fission/fission/pkg/generator/v1"
"github.com/fission/fission/pkg/utils"
)
var specDefaultEncoder = encoder.DefaultYAMLEncoder()
const (
FISSION_DEPLOYMENT_NAME_KEY = "fission-name"
FISSION_DEPLOYMENT_UID_KEY = "fission-uid"
SPEC_API_VERSION = "fission.io/v1"
ARCHIVE_URL_PREFIX string = "archive://"
SPEC_README = `
Fission Specs
=============
This is a set of specifications for a Fission app. This includes functions,
environments, and triggers; we collectively call these things "resources".
How to use these specs
----------------------
These specs are handled with the 'fission spec' command. See 'fission spec --help'.
'fission spec apply' will "apply" all resources specified in this directory to your
cluster. That means it checks what resources exist on your cluster, what resources are
specified in the specs directory, and reconciles the difference by creating, updating or
deleting resources on the cluster.
'fission spec apply' will also package up your source code (or compiled binaries) and
upload the archives to the cluster if needed. It uses 'ArchiveUploadSpec' resources in
this directory to figure out which files to archive.
You can use 'fission spec apply --watch' to watch for file changes and continuously keep
the cluster updated.
You can add YAMLs to this directory by writing them manually, but it's easier to generate
them. Use 'fission function create --spec' to generate a function spec,
'fission environment create --spec' to generate an environment spec, and so on.
You can edit any of the files in this directory, except 'fission-deployment-config.yaml',
which contains a UID that you should never change. To apply your changes simply use
'fission spec apply'.
fission-deployment-config.yaml
------------------------------
fission-deployment-config.yaml contains a UID. This UID is what fission uses to correlate
resources on the cluster to resources in this directory.
All resources created by 'fission spec apply' are annotated with this UID. Resources on
the cluster that are _not_ annotated with this UID are never modified or deleted by
fission.
`
)
// CLI spec types
type (
FissionResources struct {
DeploymentConfig types.DeploymentConfig
Packages []fv1.Package
Functions []fv1.Function
Environments []fv1.Environment
HttpTriggers []fv1.HTTPTrigger
KubernetesWatchTriggers []fv1.KubernetesWatchTrigger
TimeTriggers []fv1.TimeTrigger
MessageQueueTriggers []fv1.MessageQueueTrigger
ArchiveUploadSpecs []types.ArchiveUploadSpec
SourceMap SourceMap
}
ResourceApplyStatus struct {
Created []*metav1.ObjectMeta
Updated []*metav1.ObjectMeta
Deleted []*metav1.ObjectMeta
}
Location struct {
Path string
Line int
}
SourceMap struct {
// kind -> namespace -> name -> location
Locations map[string](map[string](map[string]Location))
}
)
func MapKey(m *metav1.ObjectMeta) string {
return fmt.Sprintf("%v:%v", m.Namespace, m.Name)
}
// Save saves object encoded value to spec file under given spec directory
func Save(data []byte, specDir string, specFile string) error {
// verify
if _, err := os.Stat(filepath.Join(specDir, "fission-deployment-config.yaml")); os.IsNotExist(err) {
return errors.Wrap(err, "Couldn't find specs, run `fission spec init` first")
}
filename := filepath.Join(specDir, specFile)
// check if the file is new
newFile := false
if _, err := os.Stat(filename); os.IsNotExist(err) {
newFile = true
}
// open spec file to append or write
f, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return errors.Wrap(err, "couldn't create spec file")
}
defer f.Close()
// if we're appending, add a yaml document separator
if !newFile {
_, err = f.Write([]byte("\n---\n"))
if err != nil {
return errors.Wrap(err, "couldn't write to spec file")
}
}
// write our resource
_, err = f.Write(data)
if err != nil {
return errors.Wrap(err, "couldn't write to spec file")
}
return nil
}
// called from `fission * create --spec`
func SpecSave(resource interface{}, specFile string) error {
specDir := "specs"
// make sure we're writing a known type
var data []byte
var err error
switch typedres := resource.(type) {
case types.ArchiveUploadSpec:
typedres.Kind = "ArchiveUploadSpec"
data, err = yaml.Marshal(typedres)
case fv1.Package:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "Package"
data, err = yaml.Marshal(typedres)
case fv1.Function:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "Function"
data, err = yaml.Marshal(typedres)
case fv1.Environment:
env := resource.(fv1.Environment)
var generator *v1generator.EnvironmentGenerator
generator, err = v1generator.CreateEnvironmentGeneratorFromObj(&env)
if err != nil {
return err
}
data, err = generator.StructuredGenerate(specDefaultEncoder)
case fv1.HTTPTrigger:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "HTTPTrigger"
data, err = yaml.Marshal(typedres)
case fv1.KubernetesWatchTrigger:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "KubernetesWatchTrigger"
data, err = yaml.Marshal(typedres)
case fv1.MessageQueueTrigger:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "MessageQueueTrigger"
data, err = yaml.Marshal(typedres)
case fv1.TimeTrigger:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "TimeTrigger"
data, err = yaml.Marshal(typedres)
case fv1.Recorder:
typedres.TypeMeta.APIVersion = fv1.CRD_VERSION
typedres.TypeMeta.Kind = "Recorder"
data, err = yaml.Marshal(typedres)
default:
return fmt.Errorf("can't save resource %#v", resource)
}
if err != nil {
return errors.Wrap(err, "Couldn't marshal YAML")
}
return Save(data, specDir, specFile)
}
// validateFunctionReference checks a function reference
func (fr *FissionResources) validateFunctionReference(functions map[string]bool, kind string, meta *metav1.ObjectMeta, funcRef fv1.FunctionReference) error {
if funcRef.Type == fv1.FunctionReferenceTypeFunctionName {
// triggers only reference functions in their own namespace
namespace := meta.Namespace
name := funcRef.Name
m := &metav1.ObjectMeta{
Namespace: namespace,
Name: name,
}
if _, ok := functions[MapKey(m)]; !ok {
return fmt.Errorf("%v: %v '%v' references unknown function '%v'",
fr.SourceMap.Locations[kind][meta.Namespace][meta.Name],
kind,
meta.Name,
name)
} else {
functions[MapKey(m)] = true
}
}
return nil
}
func (fr *FissionResources) Validate(input cli.Input) error {
result := utils.MultiErrorWithFormat()
// check references: both dangling refs + garbage
// packages -> archives
// functions -> packages
// functions -> environments + shared environments between functions [TODO]
// functions -> secrets + configmaps (same ns) [TODO]
// triggers -> functions
// index archives
archives := make(map[string]bool)
for _, a := range fr.ArchiveUploadSpecs {
archives[a.Name] = false
}
// index packages, check outgoing refs, mark archives that are referenced
packages := make(map[string]bool)
for _, p := range fr.Packages {
packages[MapKey(&p.Metadata)] = false
// check archive refs from package
aname := strings.TrimPrefix(p.Spec.Source.URL, ARCHIVE_URL_PREFIX)
if len(aname) > 0 {
if _, ok := archives[aname]; !ok {
result = multierror.Append(result, fmt.Errorf(
"%v: package '%v' references unknown source archive %v%v",
fr.SourceMap.Locations["Package"][p.Metadata.Namespace][p.Metadata.Name],
p.Metadata.Name,
ARCHIVE_URL_PREFIX,
aname))
} else {
archives[aname] = true
}
}
aname = strings.TrimPrefix(p.Spec.Deployment.URL, ARCHIVE_URL_PREFIX)
if len(aname) > 0 {
if _, ok := archives[aname]; !ok {
result = multierror.Append(result, fmt.Errorf(
"%v: package '%v' references unknown deployment archive %v%v",
fr.SourceMap.Locations["Package"][p.Metadata.Namespace][p.Metadata.Name],
p.Metadata.Name,
ARCHIVE_URL_PREFIX,
aname))
} else {
archives[aname] = true
}
}
result = multierror.Append(result, p.Validate())
}
// error on unreferenced archives
for name, referenced := range archives {
if !referenced {
result = multierror.Append(result, fmt.Errorf(
"%v: archive '%v' is not used in any package",
fr.SourceMap.Locations["ArchiveUploadSpec"][""][name],
name))
}
}
// index functions, check function package refs, mark referenced packages
functions := make(map[string]bool)
for _, f := range fr.Functions {
functions[MapKey(&f.Metadata)] = false
pkgMeta := &metav1.ObjectMeta{
Name: f.Spec.Package.PackageRef.Name,
Namespace: f.Spec.Package.PackageRef.Namespace,
}
// check package ref from function
packageRefExists := func() bool {
_, ok := packages[MapKey(pkgMeta)]
return ok
}
// check that the package referenced by each function is in the same ns as the function
packageRefInFuncNs := func(f *fv1.Function) bool {
return f.Spec.Package.PackageRef.Namespace == f.Metadata.Namespace
}
if !packageRefInFuncNs(&f) {
result = multierror.Append(result, fmt.Errorf(
"%v: function '%v' references a package outside of its namespace %v/%v",
fr.SourceMap.Locations["Function"][f.Metadata.Namespace][f.Metadata.Name],
f.Metadata.Name,
f.Spec.Package.PackageRef.Namespace,
f.Spec.Package.PackageRef.Name))
} else if !packageRefExists() {
result = multierror.Append(result, fmt.Errorf(
"%v: function '%v' references unknown package %v/%v",
fr.SourceMap.Locations["Function"][f.Metadata.Namespace][f.Metadata.Name],
f.Metadata.Name,
pkgMeta.Namespace,
pkgMeta.Name))
} else {
packages[MapKey(pkgMeta)] = true
}
client, err := util.GetServer(input)
if err != nil {
return err
}
for _, cm := range f.Spec.ConfigMaps {
_, err := client.ConfigMapGet(&metav1.ObjectMeta{
Name: cm.Name,
Namespace: cm.Namespace,
})
if k8serrors.IsNotFound(err) {
console.Warn(fmt.Sprintf("Configmap %s is referred in the spec but not present in the cluster", cm.Name))
}
}
for _, s := range f.Spec.Secrets {
_, err := client.SecretGet(&metav1.ObjectMeta{
Name: s.Name,
Namespace: s.Namespace,
})
if k8serrors.IsNotFound(err) {
console.Warn(fmt.Sprintf("Secret %s is referred in the spec but not present in the cluster", s.Name))
}
}
result = multierror.Append(result, f.Validate())
}
// error on unreferenced packages
for key, referenced := range packages {
ks := strings.Split(key, ":")
namespace, name := ks[0], ks[1]
if !referenced {
result = multierror.Append(result, fmt.Errorf(
"%v: package '%v' is not used in any function",
fr.SourceMap.Locations["Package"][namespace][name],
name))
}
}
// check function refs from triggers
for _, t := range fr.HttpTriggers {
err := fr.validateFunctionReference(functions, t.Kind, &t.Metadata, t.Spec.FunctionReference)
if err != nil {
result = multierror.Append(result, err)
}
if len(t.Spec.Host) > 0 {
console.Warn(fmt.Sprintf("Host in HTTPTrigger spec.Host is now marked as deprecated, see 'help' for details"))
}
result = multierror.Append(result, t.Validate())
}
for _, t := range fr.KubernetesWatchTriggers {
err := fr.validateFunctionReference(functions, t.Kind, &t.Metadata, t.Spec.FunctionReference)
if err != nil {
result = multierror.Append(result, err)
}
result = multierror.Append(result, t.Validate())
}
for _, t := range fr.TimeTriggers {
err := fr.validateFunctionReference(functions, t.Kind, &t.Metadata, t.Spec.FunctionReference)
if err != nil {
result = multierror.Append(result, err)
}
result = multierror.Append(result, t.Validate())
}
for _, t := range fr.MessageQueueTriggers {
err := fr.validateFunctionReference(functions, t.Kind, &t.Metadata, t.Spec.FunctionReference)
if err != nil {
result = multierror.Append(result, err)
}
result = multierror.Append(result, t.Validate())
}
// we do not error on unreferenced functions (you can call a function through workflows,
// `fission function test`, etc.)
// Index envs, warn on functions referencing an environment for which spes does not exist
environments := make(map[string]struct{})
for _, e := range fr.Environments {
environments[fmt.Sprintf("%s:%s", e.Metadata.Name, e.Metadata.Namespace)] = struct{}{}
if ((e.Spec.Runtime.Container != nil) && (e.Spec.Runtime.PodSpec != nil)) || ((e.Spec.Builder.Container != nil) && (e.Spec.Builder.PodSpec != nil)) {
console.Warn("You have provided both - container spec and pod spec and while merging the pod spec will take precedence.")
}
// Unlike CLI can change the environment version silently,
// we have to warn the user to modify spec file when this takes place.
if e.Spec.Version < 3 && e.Spec.Poolsize != 0 {
console.Warn("Poolsize can only be configured when environment version equals to 3, default poolsize 3 will be used for creating environment pool.")
}
}
for _, f := range fr.Functions {
if _, ok := environments[fmt.Sprintf("%s:%s", f.Spec.Environment.Name, f.Spec.Environment.Namespace)]; !ok {
console.Warn(fmt.Sprintf("Environment %s is referenced in function %s but not declared in specs", f.Spec.Environment.Name, f.Metadata.Name))
}
strategy := f.Spec.InvokeStrategy.ExecutionStrategy
if strategy.ExecutorType == fv1.ExecutorTypeNewdeploy && strategy.SpecializationTimeout < fv1.DefaultSpecializationTimeOut {
console.Warn(fmt.Sprintf("SpecializationTimeout in function spec.InvokeStrategy.ExecutionStrategy should be a value equal to or greater than %v", fv1.DefaultSpecializationTimeOut))
}
if f.Spec.FunctionTimeout <= 0 {
console.Warn(fmt.Sprintf("FunctionTimeout in function spec should be a field which should have a value greater than 0"))
}
}
// (ErrorOrNil returns nil if there were no errors appended.)
return result.ErrorOrNil()
}
// Keep track of source location of resources, and track duplicates
func (fr *FissionResources) trackSourceMap(kind string, newobj *metav1.ObjectMeta, loc *Location) error {
if _, exists := fr.SourceMap.Locations[kind]; !exists {
fr.SourceMap.Locations[kind] = make(map[string](map[string]Location))
}
if _, exists := fr.SourceMap.Locations[kind][newobj.Namespace]; !exists {
fr.SourceMap.Locations[kind][newobj.Namespace] = make(map[string]Location)
}
// check for duplicate resources
oldloc, exists := fr.SourceMap.Locations[kind][newobj.Namespace][newobj.Name]
if exists {
return fmt.Errorf("%v: Duplicate %v '%v', first defined in %v", loc, kind, newobj.Name, oldloc)
}
// track new resource
fr.SourceMap.Locations[kind][newobj.Namespace][newobj.Name] = *loc
return nil
}
// ParseYaml takes one yaml document, figures out its type, parses it, and puts it in
// the right list in the given fission resources set.
func (fr *FissionResources) ParseYaml(b []byte, loc *Location) error {
var m *metav1.ObjectMeta
// Figure out the object type by unmarshaling into the TypeMeta struct; then
// unmarshal again into the "real" struct once we know the type.
var tm types.TypeMeta
err := yaml.Unmarshal(b, &tm)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to decode yaml %v", string(b)))
}
switch tm.Kind {
case "Package":
var v fv1.Package
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &v.Metadata
fr.Packages = append(fr.Packages, v)
case "Function":
var v fv1.Function
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &v.Metadata
fr.Functions = append(fr.Functions, v)
case "Environment":
var v fv1.Environment
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &v.Metadata
fr.Environments = append(fr.Environments, v)
case "HTTPTrigger":
var v fv1.HTTPTrigger
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
// TODO move to validator
if !strings.HasPrefix(v.Spec.RelativeURL, "/") {
v.Spec.RelativeURL = fmt.Sprintf("/%s", v.Spec.RelativeURL)
}
m = &v.Metadata
fr.HttpTriggers = append(fr.HttpTriggers, v)
case "KubernetesWatchTrigger":
var v fv1.KubernetesWatchTrigger
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &v.Metadata
fr.KubernetesWatchTriggers = append(fr.KubernetesWatchTriggers, v)
case "TimeTrigger":
var v fv1.TimeTrigger
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &v.Metadata
fr.TimeTriggers = append(fr.TimeTriggers, v)
case "MessageQueueTrigger":
var v fv1.MessageQueueTrigger
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &v.Metadata
fr.MessageQueueTriggers = append(fr.MessageQueueTriggers, v)
// The following are not CRDs
case "DeploymentConfig":
var v types.DeploymentConfig
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
fr.DeploymentConfig = v
case "ArchiveUploadSpec":
var v types.ArchiveUploadSpec
err = yaml.Unmarshal(b, &v)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("Failed to parse %v in %v", tm.Kind, loc))
}
m = &metav1.ObjectMeta{
Name: v.Name,
Namespace: "",
}
fr.ArchiveUploadSpecs = append(fr.ArchiveUploadSpecs, v)
default:
// no need to error out just because there's some extra files around;
// also good for compatibility.
console.Warn(fmt.Sprintf("Ignoring unknown type %v in %v", tm.Kind, loc))
}
// add to source map, check for duplicates
if m != nil {
err = fr.trackSourceMap(tm.Kind, m, loc)
if err != nil {
return err
}
}
return nil
}
// Returns metadata if the given resource exists in the specs, nil
// otherwise. compareMetadata and compareSpec control how the
// equality check is performed.
func (fr *FissionResources) SpecExists(resource interface{}, compareMetadata bool, compareSpec bool) *metav1.ObjectMeta {
switch typedres := resource.(type) {
case *types.ArchiveUploadSpec:
for _, aus := range fr.ArchiveUploadSpecs {
if compareMetadata && aus.Name != typedres.Name {
continue
}
if compareSpec &&
!(reflect.DeepEqual(aus.RootDir, typedres.RootDir) &&
reflect.DeepEqual(aus.IncludeGlobs, typedres.IncludeGlobs) &&
reflect.DeepEqual(aus.ExcludeGlobs, typedres.ExcludeGlobs)) {
continue
}
return &metav1.ObjectMeta{Name: aus.Name}
}
return nil
case *fv1.Package:
for _, p := range fr.Packages {
if compareMetadata && !reflect.DeepEqual(p.Metadata, typedres.Metadata) {
continue
}
if compareSpec && !reflect.DeepEqual(p.Spec, typedres.Spec) {
continue
}
return &p.Metadata
}
return nil
default:
// XXX not implemented
return nil
}
}
func (loc Location) String() string {
return fmt.Sprintf("%v:%v", loc.Path, loc.Line)
}