From 6295812aae73aaac5d5d21fd21c7fba8fff53509 Mon Sep 17 00:00:00 2001 From: bryanl <bryanliles@gmail.com> Date: Tue, 13 Feb 2018 16:29:53 -0500 Subject: [PATCH] Introduce component namespaces re: #292 Signed-off-by: bryanl <bryanliles@gmail.com> --- Makefile | 5 +- cmd/prototype.go | 6 +- cmd/root.go | 41 ++-- component/component.go | 353 ++++++++++++++++++++++++++++++ component/component_test.go | 426 ++++++++++++++++++++++++++++++++++++ component/create.go | 208 ++++++++++++++++++ component/create_test.go | 151 +++++++++++++ metadata/component.go | 141 ++++-------- metadata/component_test.go | 44 +--- metadata/environment.go | 9 +- metadata/interface.go | 2 +- metadata/manager.go | 3 +- pkg/kubecfg/param.go | 9 +- template/expander.go | 6 +- 14 files changed, 1247 insertions(+), 157 deletions(-) create mode 100644 component/component.go create mode 100644 component/component_test.go create mode 100644 component/create.go create mode 100644 component/create_test.go diff --git a/Makefile b/Makefile index 06e6e3ec..a9e3d03a 100644 --- a/Makefile +++ b/Makefile @@ -30,8 +30,7 @@ GUESTBOOK_FILE = examples/guestbook.jsonnet DOC_GEN_FILE = ./docs/generate/update-generated-docs.sh DOC_TEST_FILE = ./docs/generate/verify-generated-docs.sh JSONNET_FILES = $(KCFG_TEST_FILE) $(GUESTBOOK_FILE) -# TODO: Simplify this once ./... ignores ./vendor -GO_PACKAGES = ./cmd/... ./utils/... ./pkg/... ./metadata/... ./prototype/... +GO_PACKAGES = ./... # Default cluster from this config is used for integration tests KUBECONFIG = $(HOME)/.kube/config @@ -60,7 +59,7 @@ integrationtest: ks $(GINKGO) -tags 'integration' integration -- -fixtures $(INTEGRATION_TEST_FIXTURES) -kubeconfig $(KUBECONFIG) -ksonnet-bin $(abspath $<) vet: - $(GO) vet $(GO_FLAGS) $(GO_PACKAGES) + $(GO) vet $(GO_PACKAGES) fmt: $(GOFMT) -s -w $(shell $(GO) list -f '{{.Dir}}' $(GO_PACKAGES)) diff --git a/cmd/prototype.go b/cmd/prototype.go index 821c5892..771a0a2a 100644 --- a/cmd/prototype.go +++ b/cmd/prototype.go @@ -20,6 +20,8 @@ import ( "os" "strings" + "github.com/ksonnet/ksonnet/component" + "github.com/spf13/pflag" "github.com/ksonnet/ksonnet/metadata" @@ -417,7 +419,9 @@ var prototypeUseCmd = &cobra.Command{ return err } - text, err := expandPrototype(proto, templateType, params, componentName) + _, prototypeName := component.ExtractNamespacedComponent(appFs, cwd, componentName) + + text, err := expandPrototype(proto, templateType, params, prototypeName) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 9dd9f722..4311834c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,6 +38,7 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/tools/clientcmd" + "github.com/ksonnet/ksonnet/component" "github.com/ksonnet/ksonnet/metadata" str "github.com/ksonnet/ksonnet/strings" "github.com/ksonnet/ksonnet/template" @@ -433,40 +434,54 @@ func (te *cmdObjExpander) Expand() ([]*unstructured.Unstructured, error) { } _, vendorPath := manager.LibPaths() - libPath, mainPath, paramsPath, err := manager.EnvPaths(te.config.env) + libPath, mainPath, _, err := manager.EnvPaths(te.config.env) if err != nil { return nil, err } expander.FlagJpath = append([]string{string(vendorPath), string(libPath)}, expander.FlagJpath...) - componentPaths, err := manager.ComponentPaths() + namespacedComponentPaths, err := component.MakePathsByNamespace(te.config.fs, manager, te.config.cwd, te.config.env) if err != nil { return nil, errors.Wrap(err, "component paths") } - baseObj, err := constructBaseObj(componentPaths, te.config.components) - if err != nil { - return nil, errors.Wrap(err, "construct base object") - } - // // Set up ExtCodes to resolve runtime variables such as the environment namespace. // - params := importParams(string(paramsPath)) envSpec, err := importEnv(manager, te.config.env) if err != nil { return nil, err } - expander.ExtCodes = append([]string{baseObj, params, envSpec}, expander.ExtCodes...) + baseCodes := expander.ExtCodes - // - // Expand the ksonnet app as rendered for environment `env`. - // + slUnstructured := make([]*unstructured.Unstructured, 0) + for ns, componentPaths := range namespacedComponentPaths { + + paramsPath := ns.ParamsPath() + params := importParams(string(paramsPath)) + + baseObj, err := constructBaseObj(componentPaths, te.config.components) + if err != nil { + return nil, errors.Wrap(err, "construct base object") + } + + // + // Expand the ksonnet app as rendered for environment `env`. + // + expander.ExtCodes = append([]string{baseObj, params, envSpec}, baseCodes...) + u, err := expander.Expand([]string{string(mainPath)}) + if err != nil { + return nil, errors.Wrapf(err, "generate objects for namespace %s", ns.Path) + } + + slUnstructured = append(slUnstructured, u...) + } + + return slUnstructured, nil - return expander.Expand([]string{string(mainPath)}) } // constructBaseObj constructs the base Jsonnet object that represents k-v diff --git a/component/component.go b/component/component.go new file mode 100644 index 00000000..4c3f6e5e --- /dev/null +++ b/component/component.go @@ -0,0 +1,353 @@ +// Copyright 2017 The kubecfg 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 component + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/ksonnet/ksonnet/metadata/app" + "github.com/pkg/errors" + "github.com/spf13/afero" +) + +const ( + // componentsDir is the name of the directory which houses components. + componentsRoot = "components" + // paramsFile is the params file for a component namespace. + paramsFile = "params.libsonnet" +) + +// Path returns returns the file system path for a component. +func Path(fs afero.Fs, root, name string) (string, error) { + ns, localName := ExtractNamespacedComponent(fs, root, name) + + fis, err := afero.ReadDir(fs, ns.Dir()) + if err != nil { + return "", err + } + + var fileName string + files := make(map[string]bool) + + for _, fi := range fis { + if fi.IsDir() { + continue + } + + base := strings.TrimSuffix(fi.Name(), filepath.Ext(fi.Name())) + if _, ok := files[base]; ok { + return "", errors.Errorf("Found multiple component files with component name %q", name) + } + files[base] = true + + if base == localName { + fileName = fi.Name() + } + } + + if fileName == "" { + return "", errors.Errorf("No component name %q found", name) + } + + return filepath.Join(ns.Dir(), fileName), nil +} + +// Namespace is a component namespace. +type Namespace struct { + // Path is the path of the component namespace. + Path string + + root string + fs afero.Fs +} + +// ExtractNamespacedComponent extracts a namespace and a component from a path. +func ExtractNamespacedComponent(fs afero.Fs, root, path string) (Namespace, string) { + path, component := filepath.Split(path) + path = strings.TrimSuffix(path, "/") + ns := Namespace{Path: path, root: root, fs: fs} + return ns, component +} + +// ParamsPath generates the path to params.libsonnet for a namespace. +func (n *Namespace) ParamsPath() string { + return filepath.Join(n.Dir(), paramsFile) +} + +// ComponentPaths are the absolute paths to all the components in a namespace. +func (n *Namespace) ComponentPaths() ([]string, error) { + dir := n.Dir() + fis, err := afero.ReadDir(n.fs, dir) + if err != nil { + return nil, errors.Wrap(err, "read component dir") + } + + var paths []string + for _, fi := range fis { + if fi.IsDir() { + continue + } + + if strings.HasSuffix(fi.Name(), ".jsonnet") { + paths = append(paths, filepath.Join(dir, fi.Name())) + } + } + + sort.Strings(paths) + + return paths, nil +} + +// Components returns the components in a namespace. +func (n *Namespace) Components() ([]string, error) { + paths, err := n.ComponentPaths() + if err != nil { + return nil, err + } + + dir := filepath.Join(n.root, componentsRoot) + "/" + + var names []string + for _, path := range paths { + name := strings.TrimPrefix(path, dir) + name = strings.TrimSuffix(name, filepath.Ext(name)) + names = append(names, name) + } + + return names, nil +} + +// Dir is the absolute directory for a namespace. +func (n *Namespace) Dir() string { + path := []string{n.root, componentsRoot} + if n.Path != "" { + path = append(path, strings.Split(n.Path, "/")...) + } + + return filepath.Join(path...) +} + +// Namespaces returns all component namespaces +func Namespaces(fs afero.Fs, root string) ([]Namespace, error) { + componentRoot := filepath.Join(root, componentsRoot) + + var namespaces []Namespace + + err := afero.Walk(fs, componentRoot, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if fi.IsDir() { + ok, err := isComponentDir(fs, path) + if err != nil { + return err + } + + if ok { + nsPath := strings.TrimPrefix(path, componentRoot) + nsPath = strings.TrimPrefix(nsPath, "/") + ns := Namespace{Path: nsPath, fs: fs, root: root} + namespaces = append(namespaces, ns) + } + } + + return nil + }) + + if err != nil { + return nil, errors.Wrap(err, "walk component path") + } + + sort.Slice(namespaces, func(i, j int) bool { + return namespaces[i].Path < namespaces[j].Path + }) + + return namespaces, nil +} + +func isComponentDir(fs afero.Fs, path string) (bool, error) { + files, err := afero.ReadDir(fs, path) + if err != nil { + return false, errors.Wrapf(err, "read files in %s", path) + } + + for _, file := range files { + if file.Name() == paramsFile { + return true, nil + } + } + + return false, nil +} + +// AppSpecer is implemented by any value that has a AppSpec method. The AppSpec method is +// used to retrieve a ksonnet AppSpec. +type AppSpecer interface { + AppSpec() (*app.Spec, error) +} + +// MakePathsByNamespace creates a map of component paths categorized by namespace. +func MakePathsByNamespace(fs afero.Fs, appSpecer AppSpecer, root, env string) (map[Namespace][]string, error) { + paths, err := MakePaths(fs, appSpecer, root, env) + if err != nil { + return nil, err + } + + m := make(map[Namespace][]string) + + for i := range paths { + prefix := root + "/components/" + if strings.HasSuffix(root, "/") { + prefix = root + "components/" + } + path := strings.TrimPrefix(paths[i], prefix) + ns, _ := ExtractNamespacedComponent(fs, root, path) + if _, ok := m[ns]; !ok { + m[ns] = make([]string, 0) + } + + m[ns] = append(m[ns], paths[i]) + } + + return m, nil +} + +// MakePaths creates a slice of component paths +func MakePaths(fs afero.Fs, appSpecer AppSpecer, root, env string) ([]string, error) { + cpl, err := newComponentPathLocator(fs, appSpecer, env) + if err != nil { + return nil, errors.Wrap(err, "create component path locator") + } + + return cpl.Locate(root) +} + +type componentPathLocator struct { + fs afero.Fs + envSpec *app.EnvironmentSpec +} + +func newComponentPathLocator(fs afero.Fs, appSpecer AppSpecer, env string) (*componentPathLocator, error) { + if appSpecer == nil { + return nil, errors.New("appSpecer is nil") + } + + if fs == nil { + return nil, errors.New("fs is nil") + } + + appSpec, err := appSpecer.AppSpec() + if err != nil { + return nil, errors.Wrap(err, "lookup application spec") + } + + envSpec, ok := appSpec.GetEnvironmentSpec(env) + if !ok { + return nil, errors.Errorf("can't find %s environment", env) + } + + return &componentPathLocator{ + fs: fs, + envSpec: envSpec, + }, nil +} + +func (cpl *componentPathLocator) Locate(root string) ([]string, error) { + if len(cpl.envSpec.Targets) == 0 { + return cpl.defaultPaths(root) + } + + var paths []string + + for _, target := range cpl.envSpec.Targets { + childPaths, err := cpl.expandPath(root, target) + if err != nil { + return nil, errors.Wrapf(err, "unable to expand %s", target) + } + paths = append(paths, childPaths...) + } + + sort.Strings(paths) + + return paths, nil +} + +// expandPath take a root and a target and returns all the jsonnet components in descendant paths. +func (cpl *componentPathLocator) expandPath(root, target string) ([]string, error) { + path := filepath.Join(root, componentsRoot, target) + fi, err := cpl.fs.Stat(path) + if err != nil { + return nil, err + } + + var paths []string + + walkFn := func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if !fi.IsDir() && isComponent(path) { + paths = append(paths, path) + } + + return nil + } + + if fi.IsDir() { + rootPath := filepath.Join(root, componentsRoot, fi.Name()) + if err := afero.Walk(cpl.fs, rootPath, walkFn); err != nil { + return nil, errors.Wrapf(err, "search for components in %s", fi.Name()) + } + } else if isComponent(fi.Name()) { + paths = append(paths, path) + } + + return paths, nil +} + +func (cpl *componentPathLocator) defaultPaths(root string) ([]string, error) { + var paths []string + + walkFn := func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if !fi.IsDir() && isComponent(path) { + paths = append(paths, path) + } + + return nil + } + + componentRoot := filepath.Join(root, componentsRoot) + + if err := afero.Walk(cpl.fs, componentRoot, walkFn); err != nil { + return nil, errors.Wrap(err, "search for components") + } + + return paths, nil +} + +// isComponent reports if a file is a component. Components have a `jsonnet` extension. +func isComponent(path string) bool { + return filepath.Ext(path) == ".jsonnet" +} diff --git a/component/component_test.go b/component/component_test.go new file mode 100644 index 00000000..30ee8fde --- /dev/null +++ b/component/component_test.go @@ -0,0 +1,426 @@ +// Copyright 2017 The kubecfg 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 component + +import ( + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ksonnet/ksonnet/metadata/app" +) + +var ( + existingPaths = []string{ + "/app.yaml", + "/components/a.jsonnet", + "/components/b.jsonnet", + "/components/other", + "/components/params.libsonnet", + "/components/nested/a.jsonnet", + "/components/nested/params.libsonnet", + "/components/nested/very/deeply/c.jsonnet", + "/components/nested/very/deeply/params.libsonnet", + "/components/shallow/c.jsonnet", + "/components/shallow/params.libsonnet", + } + + invalidPaths = []string{ + "/app.yaml", + "/components/a.jsonnet", + "/components/a.txt", + } +) + +type stubAppSpecer struct { + appSpec *app.Spec + err error +} + +var _ AppSpecer = (*stubAppSpecer)(nil) + +func newStubAppSpecer(appSpec *app.Spec) *stubAppSpecer { + return &stubAppSpecer{appSpec: appSpec} +} + +func (s *stubAppSpecer) AppSpec() (*app.Spec, error) { + return s.appSpec, s.err +} + +func makePaths(t *testing.T, fs afero.Fs, paths []string) { + for _, path := range paths { + dir := filepath.Dir(path) + err := fs.MkdirAll(dir, 0755) + require.NoError(t, err) + + _, err = fs.Create(path) + require.NoError(t, err) + } +} + +func TestPath(t *testing.T) { + + cases := []struct { + name string + paths []string + in string + expected string + isErr bool + }{ + { + name: "in root namespace", + paths: existingPaths, + in: "a", + expected: "/components/a.jsonnet", + }, + { + name: "in nested namespace", + paths: existingPaths, + in: "nested/a", + expected: "/components/nested/a.jsonnet", + }, + { + name: "not found", + paths: existingPaths, + in: "z", + isErr: true, + }, + { + name: "invalid path", + paths: invalidPaths, + in: "a", + isErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + makePaths(t, fs, tc.paths) + + path, err := Path(fs, "/", tc.in) + if tc.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + + assert.Equal(t, tc.expected, path) + } + }) + } +} + +func TestExtractNamedspacedComponent(t *testing.T) { + cases := []struct { + name string + path string + nsPath string + component string + }{ + { + name: "component in root namespace", + path: "my-deployment", + nsPath: "", + component: "my-deployment", + }, + { + name: "component in root namespace", + path: "nested/my-deployment", + nsPath: "nested", + component: "my-deployment", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + root := "/" + ns, component := ExtractNamespacedComponent(fs, root, tc.path) + assert.Equal(t, tc.nsPath, ns.Path) + assert.Equal(t, component, component) + }) + } +} + +func TestNamespace_ParamsPath(t *testing.T) { + cases := []struct { + name string + nsName string + expected string + }{ + { + name: "root namespace", + expected: "/components/params.libsonnet", + }, + { + name: "nested namespace", + nsName: "nested", + expected: "/components/nested/params.libsonnet", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ns := Namespace{Path: tc.nsName, root: "/"} + assert.Equal(t, tc.expected, ns.ParamsPath()) + }) + } +} + +func TestNamespace_ComponentPaths(t *testing.T) { + cases := []struct { + name string + nsPath string + expected []string + }{ + { + name: "root namespace", + expected: []string{ + "/components/a.jsonnet", + "/components/b.jsonnet", + }, + }, + { + name: "nested namespace", + nsPath: "nested", + expected: []string{ + "/components/nested/a.jsonnet", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + makePaths(t, fs, existingPaths) + + ns := Namespace{Path: tc.nsPath, fs: fs, root: "/"} + + paths, err := ns.ComponentPaths() + require.NoError(t, err) + + require.Equal(t, tc.expected, paths) + }) + } +} + +func TestNamespace_Components(t *testing.T) { + cases := []struct { + name string + nsPath string + expected []string + }{ + { + name: "root namespace", + expected: []string{ + "a", + "b", + }, + }, + { + name: "nested namespace", + nsPath: "nested", + expected: []string{ + "nested/a", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + makePaths(t, fs, existingPaths) + + ns := Namespace{Path: tc.nsPath, fs: fs, root: "/"} + + paths, err := ns.Components() + require.NoError(t, err) + + require.Equal(t, tc.expected, paths) + }) + } +} + +func TestNamespaces(t *testing.T) { + fs := afero.NewMemMapFs() + + makePaths(t, fs, existingPaths) + + namespaces, err := Namespaces(fs, "/") + require.NoError(t, err) + + expected := []Namespace{ + {Path: "", fs: fs, root: "/"}, + {Path: "nested", fs: fs, root: "/"}, + {Path: "nested/very/deeply", fs: fs, root: "/"}, + {Path: "shallow", fs: fs, root: "/"}, + } + + assert.Equal(t, expected, namespaces) +} + +func TestMakePathsByNameSpace(t *testing.T) { + fs := afero.NewMemMapFs() + makePaths(t, fs, existingPaths) + + cases := []struct { + name string + targets []string + expected map[Namespace][]string + isErr bool + }{ + { + name: "no target paths", + expected: map[Namespace][]string{ + Namespace{fs: fs, root: "/"}: []string{ + "/components/a.jsonnet", + "/components/b.jsonnet", + }, + Namespace{fs: fs, root: "/", Path: "nested"}: []string{ + "/components/nested/a.jsonnet", + }, + Namespace{fs: fs, root: "/", Path: "nested/very/deeply"}: []string{ + "/components/nested/very/deeply/c.jsonnet", + }, + Namespace{fs: fs, root: "/", Path: "shallow"}: []string{ + "/components/shallow/c.jsonnet", + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + envSpec := &app.EnvironmentSpec{ + Targets: tc.targets, + } + + appSpec := &app.Spec{ + Environments: app.EnvironmentSpecs{"default": envSpec}, + } + appSpecer := newStubAppSpecer(appSpec) + + root := "/" + env := "default" + + paths, err := MakePathsByNamespace(fs, appSpecer, root, env) + if tc.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, paths) + } + + }) + } +} + +func TestMakePaths(t *testing.T) { + + cases := []struct { + name string + targets []string + expected []string + isErr bool + }{ + { + name: "no target paths", + expected: []string{ + "/components/a.jsonnet", + "/components/b.jsonnet", + "/components/nested/a.jsonnet", + "/components/nested/very/deeply/c.jsonnet", + "/components/shallow/c.jsonnet", + }, + }, + { + name: "jsonnet target path file", + targets: []string{ + "a.jsonnet", + }, + expected: []string{"/components/a.jsonnet"}, + }, + { + name: "jsonnet target path dir", + targets: []string{ + "nested", + }, + expected: []string{ + "/components/nested/a.jsonnet", + "/components/nested/very/deeply/c.jsonnet", + }, + }, + { + name: "jsonnet target path dir and files", + targets: []string{ + "shallow/c.jsonnet", + "nested", + }, + expected: []string{ + "/components/nested/a.jsonnet", + "/components/nested/very/deeply/c.jsonnet", + "/components/shallow/c.jsonnet", + }, + }, + { + name: "target points to missing path", + targets: []string{"missing"}, + isErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + makePaths(t, fs, existingPaths) + + envSpec := &app.EnvironmentSpec{ + Targets: tc.targets, + } + + appSpec := &app.Spec{ + Environments: app.EnvironmentSpecs{"default": envSpec}, + } + appSpecer := newStubAppSpecer(appSpec) + + root := "/" + env := "default" + + paths, err := MakePaths(fs, appSpecer, root, env) + if tc.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, paths) + } + }) + } +} + +func TestMakePaths_invalid_appSpecer(t *testing.T) { + fs := afero.NewMemMapFs() + _, err := MakePaths(fs, nil, "/", "default") + require.Error(t, err) +} + +func TestMakePaths_invalid_fs(t *testing.T) { + appSpecer := newStubAppSpecer(nil) + _, err := MakePaths(nil, appSpecer, "/", "default") + require.Error(t, err) +} diff --git a/component/create.go b/component/create.go new file mode 100644 index 00000000..ea875980 --- /dev/null +++ b/component/create.go @@ -0,0 +1,208 @@ +// Copyright 2017 The kubecfg 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 component + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + param "github.com/ksonnet/ksonnet/metadata/params" + "github.com/ksonnet/ksonnet/prototype" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +var ( + // defaultFolderPermissions are the default permissions for a folder. + defaultFolderPermissions = os.FileMode(0755) + // defaultFilePermissions are the default permission for a file. + defaultFilePermissions = os.FileMode(0644) +) + +// Create creates a component. +func Create(fs afero.Fs, root, name, text string, params param.Params, templateType prototype.TemplateType) (string, error) { + cc, err := newComponentCreator(fs, root) + if err != nil { + return "", errors.Wrap(err, "initialize component creator") + } + + return cc.Create(name, text, params, templateType) +} + +type componentCreator struct { + fs afero.Fs + root string +} + +func newComponentCreator(fs afero.Fs, root string) (*componentCreator, error) { + if fs == nil { + return nil, errors.New("fs is nil") + } + + if root == "" { + return nil, errors.New("invalid ksonnet root") + } + + return &componentCreator{fs: fs, root: root}, nil +} + +func (cc *componentCreator) Create(name, text string, params param.Params, templateType prototype.TemplateType) (string, error) { + if !isValidName(name) { + return "", errors.Errorf("Component name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name) + } + + nsName, componentName := namespaceComponent(name) + + componentDir, componentPath, err := cc.location(nsName, componentName, templateType) + if err != nil { + return "", errors.Wrap(err, "generate component location") + } + + paramsPath := filepath.Join(componentDir, "params.libsonnet") + + exists, err := afero.Exists(cc.fs, componentDir) + if err != nil { + return "", errors.Wrapf(err, "check if %s exists", componentDir) + } + + if !exists { + if err = cc.createNamespace(componentDir, paramsPath); err != nil { + return "", err + } + } + + exists, err = afero.Exists(cc.fs, componentPath) + if err != nil { + return "", errors.Wrapf(err, "check if %s exists", componentPath) + } + + if exists { + return "", errors.Errorf("component with name '%s' already exists", name) + } + + log.Infof("Writing component at '%s'", componentPath) + if err := afero.WriteFile(cc.fs, componentPath, []byte(text), defaultFilePermissions); err != nil { + return "", errors.Wrapf(err, "write component %s") + } + + log.Debugf("Writing component parameters at '%s/%s", componentsRoot, name) + + if err := cc.writeParams(componentName, paramsPath, params); err != nil { + return "", errors.Wrapf(err, "write parameters") + } + + return componentPath, nil +} + +// location returns the dir and full path for the component. +func (cc *componentCreator) location(nsName, name string, templateType prototype.TemplateType) (string, string, error) { + componentDir := filepath.Join(cc.root, componentsRoot, nsName) + componentPath := filepath.Join(componentDir, name) + switch templateType { + case prototype.YAML: + componentPath = componentPath + ".yaml" + case prototype.JSON: + componentPath = componentPath + ".json" + case prototype.Jsonnet: + componentPath = componentPath + ".jsonnet" + default: + return "", "", errors.Errorf("Unrecognized prototype template type '%s'", templateType) + } + + return componentDir, componentPath, nil +} + +func (cc *componentCreator) createNamespace(componentDir, paramsPath string) error { + if err := cc.fs.MkdirAll(componentDir, defaultFolderPermissions); err != nil { + return errors.Wrapf(err, "create component dir %s", componentDir) + } + + if err := afero.WriteFile(cc.fs, paramsPath, GenParamsContent(), defaultFilePermissions); err != nil { + return errors.Wrap(err, "create component params") + } + + return nil +} + +func (cc *componentCreator) writeParams(name, paramsPath string, params param.Params) error { + text, err := afero.ReadFile(cc.fs, paramsPath) + if err != nil { + return err + } + + appended, err := param.AppendComponent(name, string(text), params) + if err != nil { + return err + } + + return afero.WriteFile(cc.fs, paramsPath, []byte(appended), defaultFilePermissions) +} + +// isValidName returns true if a name (e.g., for an environment) is valid. +// A component is valid if it does not contain punctuation, whitespace, leading or +// trailing slashes. +func isValidName(name string) bool { + // No unicode whitespace is allowed. `Fields` doesn't handle trailing or + // leading whitespace. + fields := strings.Fields(name) + if len(fields) > 1 || len(strings.TrimSpace(name)) != len(name) { + return false + } + + hasPunctuation := regexp.MustCompile(`[\\,;':!()?"{}\[\]*&%@$]+`).MatchString + hasTrailingSlashes := regexp.MustCompile(`/+$`).MatchString + hasLeadingSlashes := regexp.MustCompile(`^/+`).MatchString + return len(name) != 0 && !hasPunctuation(name) && !hasTrailingSlashes(name) && !hasLeadingSlashes(name) +} + +func namespaceComponent(name string) (string, string) { + parts := strings.Split(name, "/") + + if len(parts) == 1 { + return "", parts[0] + } + + var nsName []string + var componentName string + for i := range parts { + if i == len(parts)-1 { + componentName = parts[i] + break + } + + nsName = append(nsName, parts[i]) + } + + return strings.Join(nsName, "/"), componentName +} + +// GenParamsContent is the default content for params.libsonnet. +func GenParamsContent() []byte { + return []byte(`{ + global: { + // User-defined global parameters; accessible to all component and environments, Ex: + // replicas: 4, + }, + components: { + // Component-level parameters, defined initially from 'ks prototype use ...' + // Each object below should correspond to a component in the components/ directory + }, +} +`) +} diff --git a/component/create_test.go b/component/create_test.go new file mode 100644 index 00000000..f5119a0b --- /dev/null +++ b/component/create_test.go @@ -0,0 +1,151 @@ +// Copyright 2017 The kubecfg 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 component + +import ( + "path/filepath" + "testing" + + "github.com/ksonnet/ksonnet/prototype" + + "github.com/spf13/afero" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Create(t *testing.T) { + cases := []struct { + name string + isErr bool + params map[string]string + templateType prototype.TemplateType + componentDir string + ns string + componentName string + }{ + { + name: "jsonnet component", + params: map[string]string{ + "name": "name", + }, + templateType: prototype.Jsonnet, + componentDir: "/components", + componentName: "component", + }, + { + name: "yaml component", + params: map[string]string{ + "name": "name", + }, + templateType: prototype.YAML, + componentDir: "/components", + componentName: "component", + }, + { + name: "json component", + params: map[string]string{ + "name": "name", + }, + templateType: prototype.JSON, + componentDir: "/components", + componentName: "component", + }, + { + name: "invalid component", + params: map[string]string{ + "name": "name", + }, + templateType: prototype.TemplateType("unknown"), + isErr: true, + }, + { + name: "nested/component", + params: map[string]string{"name": "name"}, + templateType: prototype.Jsonnet, + componentName: "nested/component", + componentDir: "/components/nested", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + root := "/" + + name := filepath.Join(tc.ns, tc.componentName) + + path, err := Create(fs, root, name, "content", tc.params, tc.templateType) + if tc.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + + checkPath(t, fs, path) + + paramsPath := filepath.Join(tc.componentDir, tc.ns, "params.libsonnet") + checkPath(t, fs, paramsPath) + + assertComponentExt(t, path, tc.templateType) + } + }) + } +} + +func assertComponentExt(t *testing.T, filename string, templateType prototype.TemplateType) { + ext := filepath.Ext(filename) + + var got prototype.TemplateType + switch ext { + case ".yaml": + got = prototype.YAML + case ".json": + got = prototype.JSON + case ".jsonnet": + got = prototype.Jsonnet + default: + t.Errorf("unknown component extension: %s", ext) + } + + assert.Equal(t, templateType, got) +} + +func checkPath(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.Exists(fs, path) + require.NoError(t, err) + require.True(t, exists, "expected %s to exist", path) +} + +func Test_isValidName(t *testing.T) { + cases := []struct { + name string + isValid bool + }{ + { + name: "component", + isValid: true, + }, + { + name: "with spaces", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.isValid, isValidName(tc.name)) + }) + } +} diff --git a/metadata/component.go b/metadata/component.go index b5ff387b..7cf8eaca 100644 --- a/metadata/component.go +++ b/metadata/component.go @@ -16,14 +16,15 @@ package metadata import ( - "fmt" "os" "path" - "strings" + "path/filepath" + "github.com/ksonnet/ksonnet/component" param "github.com/ksonnet/ksonnet/metadata/params" "github.com/ksonnet/ksonnet/prototype" str "github.com/ksonnet/ksonnet/strings" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/spf13/afero" ) @@ -49,64 +50,47 @@ func (m *manager) ComponentPaths() ([]string, error) { } func (m *manager) GetAllComponents() ([]string, error) { - componentPaths, err := m.ComponentPaths() + namespaces, err := component.Namespaces(m.appFS, m.rootPath) if err != nil { return nil, err } var components []string - for _, p := range componentPaths { - component := strings.TrimSuffix(path.Base(p), path.Ext(p)) - components = append(components, component) + for _, ns := range namespaces { + + comps, err := ns.Components() + if err != nil { + return nil, err + } + + components = append(components, comps...) } return components, nil } func (m *manager) CreateComponent(name string, text string, params param.Params, templateType prototype.TemplateType) error { - if !isValidName(name) || strings.Contains(name, "/") { - return fmt.Errorf("Component name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name) - } - - componentPath := str.AppendToPath(m.componentsPath, name) - switch templateType { - case prototype.YAML: - componentPath = componentPath + ".yaml" - case prototype.JSON: - componentPath = componentPath + ".json" - case prototype.Jsonnet: - componentPath = componentPath + ".jsonnet" - default: - return fmt.Errorf("Unrecognized prototype template type '%s'", templateType) - } - - if exists, err := afero.Exists(m.appFS, componentPath); exists { - return fmt.Errorf("Component with name '%s' already exists", name) - } else if err != nil { - return fmt.Errorf("Could not check whether component '%s' exists:\n\n%v", name, err) - } - - log.Infof("Writing component at '%s/%s'", componentsDir, name) - err := afero.WriteFile(m.appFS, componentPath, []byte(text), defaultFilePermissions) + _, err := component.Create(m.appFS, m.rootPath, name, text, params, templateType) if err != nil { - return err + return errors.Wrap(err, "create component") } - log.Debugf("Writing component parameters at '%s/%s", componentsDir, name) - return m.writeComponentParams(name, params) + return nil } // DeleteComponent removes the component file and all references. // Write operations will happen at the end to minimalize failures that leave // the directory structure in a half-finished state. func (m *manager) DeleteComponent(name string) error { - componentPath, err := m.findComponentPath(name) + componentPath, err := component.Path(m.appFS, m.rootPath, name) if err != nil { return err } + ns, _ := component.ExtractNamespacedComponent(m.appFS, m.rootPath, name) + // Build the new component/params.libsonnet file. - componentParamsFile, err := afero.ReadFile(m.appFS, m.componentParamsPath) + componentParamsFile, err := afero.ReadFile(m.appFS, ns.ParamsPath()) if err != nil { return err } @@ -142,7 +126,7 @@ func (m *manager) DeleteComponent(name string) error { // Remove the references in component/params.libsonnet. log.Debugf("... deleting references in %s", m.componentParamsPath) - err = afero.WriteFile(m.appFS, m.componentParamsPath, []byte(componentJsonnet), defaultFilePermissions) + err = afero.WriteFile(m.appFS, ns.ParamsPath(), []byte(componentJsonnet), defaultFilePermissions) if err != nil { return err } @@ -168,11 +152,12 @@ func (m *manager) DeleteComponent(name string) error { // TODO: Remove, // references in main.jsonnet. // component references in other component files (feature does not yet exist). - log.Infof("Succesfully deleted component '%s'", name) + log.Infof("Successfully deleted component '%s'", name) return nil } func (m *manager) GetComponentParams(component string) (param.Params, error) { + log.Infof("get component params for %s", component) text, err := afero.ReadFile(m.appFS, m.componentParamsPath) if err != nil { return nil, err @@ -181,81 +166,51 @@ func (m *manager) GetComponentParams(component string) (param.Params, error) { return param.GetComponentParams(component, string(text)) } -func (m *manager) GetAllComponentParams() (map[string]param.Params, error) { - text, err := afero.ReadFile(m.appFS, m.componentParamsPath) +func (m *manager) GetAllComponentParams(root string) (map[string]param.Params, error) { + namespaces, err := component.Namespaces(m.appFS, root) if err != nil { - return nil, err + return nil, errors.Wrap(err, "find component namespaces") } - return param.GetAllComponentParams(string(text)) -} + out := make(map[string]param.Params) -func (m *manager) SetComponentParams(component string, params param.Params) error { - text, err := afero.ReadFile(m.appFS, m.componentParamsPath) - if err != nil { - return err - } + for _, ns := range namespaces { + paramsPath := filepath.Join(root, "components", ns.Path, "params.libsonnet") - jsonnet, err := param.SetComponentParams(component, string(text), params) - if err != nil { - return err - } - - return afero.WriteFile(m.appFS, m.componentParamsPath, []byte(jsonnet), defaultFilePermissions) -} - -func (m *manager) findComponentPath(name string) (string, error) { - componentPaths, err := m.ComponentPaths() - if err != nil { - log.Debugf("Failed to retrieve component paths") - return "", err - } + text, err := afero.ReadFile(m.appFS, paramsPath) + if err != nil { + return nil, err + } - var componentPath string - for _, p := range componentPaths { - fileName := path.Base(p) - component := strings.TrimSuffix(fileName, path.Ext(fileName)) + params, err := param.GetAllComponentParams(string(text)) + if err != nil { + return nil, errors.Wrapf(err, "get all component params for %s", ns.Path) + } - if component == name { - // need to make sure we don't have multiple files with the same component name - if componentPath != "" { - return "", fmt.Errorf("Found multiple component files with component name '%s'", name) + for k, v := range params { + if ns.Path != "" { + k = ns.Path + "/" + k } - componentPath = p + out[k] = v } } - if componentPath == "" { - return "", fmt.Errorf("No component with name '%s' found", name) - } - - return componentPath, nil + return out, nil } -func (m *manager) writeComponentParams(componentName string, params param.Params) error { - text, err := afero.ReadFile(m.appFS, m.componentParamsPath) +func (m *manager) SetComponentParams(path string, params param.Params) error { + ns, componentName := component.ExtractNamespacedComponent(m.appFS, m.rootPath, path) + paramsPath := ns.ParamsPath() + + text, err := afero.ReadFile(m.appFS, paramsPath) if err != nil { return err } - appended, err := param.AppendComponent(componentName, string(text), params) + jsonnet, err := param.SetComponentParams(componentName, string(text), params) if err != nil { return err } - return afero.WriteFile(m.appFS, m.componentParamsPath, []byte(appended), defaultFilePermissions) -} - -func genComponentParamsContent() []byte { - return []byte(`{ - global: { - // User-defined global parameters; accessible to all component and environments, Ex: - // replicas: 4, - }, - components: { - // Component-level parameters, defined initially from 'ks prototype use ...' - // Each object below should correspond to a component in the components/ directory - }, -} -`) + return afero.WriteFile(m.appFS, paramsPath, []byte(jsonnet), defaultFilePermissions) } diff --git a/metadata/component_test.go b/metadata/component_test.go index 4027284e..5693508d 100644 --- a/metadata/component_test.go +++ b/metadata/component_test.go @@ -17,7 +17,6 @@ package metadata import ( "fmt" "os" - "path" "sort" "strings" "testing" @@ -97,6 +96,7 @@ func TestComponentPaths(t *testing.T) { } } +// TODO: this logic and tests should be moved to the components namespace. func TestGetAllComponents(t *testing.T) { m := populateComponentPaths(t) defer cleanComponentPaths(t) @@ -107,51 +107,13 @@ func TestGetAllComponents(t *testing.T) { } expected1 := strings.TrimSuffix(componentFile1, ".jsonnet") - expected2 := strings.TrimSuffix(componentFile2, ".jsonnet") - if len(components) != 2 { - t.Fatalf("Expected exactly 2 components, got %d", len(components)) + if len(components) != 1 { + t.Fatalf("Expected exactly 1 components, got %d", len(components)) } if components[0] != expected1 { t.Fatalf("Expected component %s, got %s", expected1, components) } - if components[1] != expected2 { - t.Fatalf("Expected component %s, got %s", expected2, components) - } -} - -func TestFindComponentPath(t *testing.T) { - m := populateComponentPaths(t) - defer cleanComponentPaths(t) - - component := strings.TrimSuffix(componentFile1, path.Ext(componentFile1)) - expected := fmt.Sprintf("%s/components/%s", componentsPath, componentFile1) - path, err := m.findComponentPath(component) - if err != nil { - t.Fatalf("Failed to find component path, %v", err) - } - - if path != expected { - t.Fatalf("m.findComponentPath failed; expected '%s', got '%s'", expected, path) - } -} - -func TestGenComponentParamsContent(t *testing.T) { - expected := `{ - global: { - // User-defined global parameters; accessible to all component and environments, Ex: - // replicas: 4, - }, - components: { - // Component-level parameters, defined initially from 'ks prototype use ...' - // Each object below should correspond to a component in the components/ directory - }, -} -` - content := string(genComponentParamsContent()) - if content != expected { - t.Fatalf("Expected to generate:\n%s\n, got:\n%s", expected, content) - } } diff --git a/metadata/environment.go b/metadata/environment.go index 73900d80..06aea484 100644 --- a/metadata/environment.go +++ b/metadata/environment.go @@ -18,12 +18,14 @@ package metadata import ( "bytes" "fmt" + "os" "path" "path/filepath" "github.com/ksonnet/ksonnet/metadata/app" "github.com/ksonnet/ksonnet/metadata/lib" str "github.com/ksonnet/ksonnet/strings" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/spf13/afero" @@ -313,8 +315,13 @@ func (m *manager) GetEnvironmentParams(name string) (map[string]param.Params, er return nil, err } + cwd, err := os.Getwd() + if err != nil { + return nil, errors.Wrap(err, "get working directory") + } + // Get all component params - componentParams, err := m.GetAllComponentParams() + componentParams, err := m.GetAllComponentParams(cwd) if err != nil { return nil, err } diff --git a/metadata/interface.go b/metadata/interface.go index 9fe0996a..88407d69 100644 --- a/metadata/interface.go +++ b/metadata/interface.go @@ -49,7 +49,7 @@ type Manager interface { // Params API. SetComponentParams(component string, params param.Params) error GetComponentParams(name string) (param.Params, error) - GetAllComponentParams() (map[string]param.Params, error) + GetAllComponentParams(cwd string) (map[string]param.Params, error) // GetEnvironmentParams will take the name of an environment and return a // mapping of parameters of the form: // componentName => {param key => param val} diff --git a/metadata/manager.go b/metadata/manager.go index 6c751d45..a6c07d02 100644 --- a/metadata/manager.go +++ b/metadata/manager.go @@ -21,6 +21,7 @@ import ( "path" "path/filepath" + "github.com/ksonnet/ksonnet/component" "github.com/ksonnet/ksonnet/metadata/app" "github.com/ksonnet/ksonnet/metadata/registry" str "github.com/ksonnet/ksonnet/strings" @@ -231,7 +232,7 @@ func (m *manager) createAppDirTree(name string, appYAMLData, baseLibData []byte, }{ { m.componentParamsPath, - genComponentParamsContent(), + component.GenParamsContent(), }, { m.baseLibsonnetPath, diff --git a/pkg/kubecfg/param.go b/pkg/kubecfg/param.go index bcebf135..dba979f3 100644 --- a/pkg/kubecfg/param.go +++ b/pkg/kubecfg/param.go @@ -18,6 +18,7 @@ package kubecfg import ( "fmt" "io" + "os" "reflect" "sort" "strconv" @@ -25,6 +26,7 @@ import ( param "github.com/ksonnet/ksonnet/metadata/params" str "github.com/ksonnet/ksonnet/strings" + "github.com/pkg/errors" "github.com/fatih/color" log "github.com/sirupsen/logrus" @@ -126,6 +128,11 @@ func NewParamListCmd(component, env string) *ParamListCmd { // Run executes the displaying of params. func (c *ParamListCmd) Run(out io.Writer) error { + cwd, err := os.Getwd() + if err != nil { + return errors.Wrap(err, "get current working directory") + } + manager, err := manager() if err != nil { return err @@ -138,7 +145,7 @@ func (c *ParamListCmd) Run(out io.Writer) error { return err } } else { - params, err = manager.GetAllComponentParams() + params, err = manager.GetAllComponentParams(cwd) if err != nil { return err } diff --git a/template/expander.go b/template/expander.go index 1efe6d93..6ec4e7a6 100644 --- a/template/expander.go +++ b/template/expander.go @@ -9,6 +9,7 @@ import ( jsonnet "github.com/google/go-jsonnet" "github.com/ksonnet/ksonnet/utils" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/spf13/afero" ) @@ -42,17 +43,18 @@ func NewExpander(fs afero.Fs) Expander { func (spec *Expander) Expand(paths []string) ([]*unstructured.Unstructured, error) { vm, err := spec.jsonnetVM() if err != nil { - return nil, err + return nil, errors.Wrap(err, "initialize jsonnet VM") } res := []*unstructured.Unstructured{} for _, path := range paths { objs, err := utils.Read(spec.fs, vm, path) if err != nil { - return nil, fmt.Errorf("Error reading %s: %v", path, err) + return nil, errors.Wrapf(err, "unable to read %s", path) } res = append(res, utils.FlattenToV1(objs)...) } + return res, nil } -- GitLab