From b05cd3447dfabe9435bc91f180f87966dd542716 Mon Sep 17 00:00:00 2001 From: bryanl <bryanliles@gmail.com> Date: Thu, 1 Mar 2018 16:43:20 -0500 Subject: [PATCH] Support 0.1.0 and 0.0.1 apiVersion simultaneously Signed-off-by: bryanl <bryanliles@gmail.com> --- Makefile | 3 +- actions/upgrade.go | 27 ++ client/client.go | 11 +- cmd/param.go | 17 +- cmd/pkg.go | 6 +- cmd/registry.go | 8 +- cmd/root.go | 30 +- cmd/upgrade.go | 62 ++++ component/component.go | 36 +- component/component_test.go | 38 +- docs/cli-reference/ks.md | 1 + docs/cli-reference/ks_param_list.md | 5 +- docs/cli-reference/ks_upgrade.md | 43 +++ env/create.go | 124 +++++++ env/create_test.go | 40 +++ env/delete.go | 63 ++++ env/delete_test.go | 28 ++ env/destination.go | 48 +++ env/env.go | 62 ++++ env/env_test.go | 41 +++ env/params.go | 103 ++++++ env/params_test.go | 103 ++++++ env/rename.go | 179 ++++++++++ env/rename_test.go | 46 +++ env/testdata/app.yaml | 28 ++ env/testdata/component-params.libsonnet | 10 + env/testdata/main.jsonnet | 1 + env/testdata/params.libsonnet | 13 + env/testdata/updated-params.libsonnet | 13 + env/util_test.go | 83 +++++ generator/ksonnet.go | 45 ++- generator/ksonnet_test.go | 10 +- integration/integration_suite_test.go | 11 +- metadata/app.go | 48 --- metadata/app/app.go | 80 +++++ metadata/app/app001.go | 272 ++++++++++++++ metadata/app/app001_test.go | 204 +++++++++++ metadata/app/app010.go | 169 +++++++++ metadata/app/app010_test.go | 168 +++++++++ metadata/app/mocks/App.go | 165 +++++++++ metadata/app/schema.go | 59 +++- metadata/app/schema_test.go | 16 +- metadata/app/testdata/app001_app.yaml | 9 + metadata/app/testdata/app010_app.yaml | 34 ++ metadata/app/testdata/spec.json | 4 + metadata/app/testdata/swagger.json | 5 + metadata/app/testdata/upgrade001.txt | 45 +++ metadata/component.go | 1 - metadata/component_test.go | 70 ++-- metadata/environment.go | 450 +++--------------------- metadata/environment_test.go | 381 +++++++++----------- metadata/interface.go | 33 +- metadata/lib/clusterspec_test.go | 21 +- metadata/lib/lib.go | 26 +- metadata/lib/lib_test.go | 84 ++--- metadata/lib/testdata/k8s.libsonnet | 8 - metadata/manager.go | 46 ++- metadata/manager_test.go | 326 +++++++++-------- metadata/registry.go | 12 +- pkg/kubecfg/env.go | 13 +- pkg/kubecfg/param.go | 221 ++++++------ pkg/kubecfg/param_test.go | 62 ---- testdata/testapp/app.yaml | 2 +- 63 files changed, 3127 insertions(+), 1245 deletions(-) create mode 100644 actions/upgrade.go create mode 100644 cmd/upgrade.go create mode 100644 docs/cli-reference/ks_upgrade.md create mode 100644 env/create.go create mode 100644 env/create_test.go create mode 100644 env/delete.go create mode 100644 env/delete_test.go create mode 100644 env/destination.go create mode 100644 env/env.go create mode 100644 env/env_test.go create mode 100644 env/params.go create mode 100644 env/params_test.go create mode 100644 env/rename.go create mode 100644 env/rename_test.go create mode 100644 env/testdata/app.yaml create mode 100644 env/testdata/component-params.libsonnet create mode 100644 env/testdata/main.jsonnet create mode 100644 env/testdata/params.libsonnet create mode 100644 env/testdata/updated-params.libsonnet create mode 100644 env/util_test.go delete mode 100644 metadata/app.go create mode 100644 metadata/app/app.go create mode 100644 metadata/app/app001.go create mode 100644 metadata/app/app001_test.go create mode 100644 metadata/app/app010.go create mode 100644 metadata/app/app010_test.go create mode 100644 metadata/app/mocks/App.go create mode 100644 metadata/app/testdata/app001_app.yaml create mode 100644 metadata/app/testdata/app010_app.yaml create mode 100644 metadata/app/testdata/spec.json create mode 100644 metadata/app/testdata/swagger.json create mode 100644 metadata/app/testdata/upgrade001.txt delete mode 100644 metadata/lib/testdata/k8s.libsonnet diff --git a/Makefile b/Makefile index a9e3d03a..431ea224 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,11 @@ VERSION?=dev-$(shell date +%FT%T%z) KS_BIN?=ks APIMACHINERY_VER := $(shell dep status | grep k8s.io/apimachinery | awk '{print $$3}') +REVISION=$(shell git rev-parse HEAD) GO = go EXTRA_GO_FLAGS = -GO_FLAGS = -ldflags="-X main.version=$(VERSION) -X main.apimachineryVersion=$(APIMACHINERY_VER) $(GO_LDFLAGS)" $(EXTRA_GO_FLAGS) +GO_FLAGS = -ldflags="-X main.version=$(VERSION) -X main.apimachineryVersion=$(APIMACHINERY_VER) -X generator.revision=$(REVISION) $(GO_LDFLAGS) " $(EXTRA_GO_FLAGS) GOFMT = gofmt # GINKGO = "go test" also works if you want to avoid ginkgo tool GINKGO = ginkgo diff --git a/actions/upgrade.go b/actions/upgrade.go new file mode 100644 index 00000000..52d54e9d --- /dev/null +++ b/actions/upgrade.go @@ -0,0 +1,27 @@ +package actions + +import ( + "os" + + "github.com/ksonnet/ksonnet/metadata" +) + +// Upgrade upgrades a ksonnet application. +func Upgrade(dryRun bool) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + m, err := metadata.Find(cwd) + if err != nil { + return err + } + + a, err := m.App() + if err != nil { + return err + } + + return a.Upgrade(dryRun) +} diff --git a/client/client.go b/client/client.go index f7739d36..5561f70a 100644 --- a/client/client.go +++ b/client/client.go @@ -238,12 +238,12 @@ func (c *Config) overrideCluster(envName string) error { // log.Debugf("Validating deployment at '%s' with server '%v'", envName, reflect.ValueOf(servers).MapKeys()) - env, err := metadataManager.GetEnvironment(envName) + destination, err := metadataManager.GetDestination(envName) if err != nil { return err } - server, err := str.NormalizeURL(env.Destination.Server) + server, err := str.NormalizeURL(destination.Server()) if err != nil { return err } @@ -255,11 +255,12 @@ func (c *Config) overrideCluster(envName string) error { c.Overrides.Context.Cluster = clusterName } if c.Overrides.Context.Namespace == "" { - log.Debugf("Overwriting --namespace flag with '%s'", env.Destination.Namespace) - c.Overrides.Context.Namespace = env.Destination.Namespace + log.Debugf("Overwriting --namespace flag with '%s'", destination.Namespace()) + c.Overrides.Context.Namespace = destination.Namespace() } return nil } - return fmt.Errorf("Attempting to deploy to environment '%s' at '%s', but cannot locate a server at that address", envName, env.Destination.Server) + return fmt.Errorf("Attempting to deploy to environment '%s' at '%s', but cannot locate a server at that address", + envName, destination.Server()) } diff --git a/cmd/param.go b/cmd/param.go index 40b4fc04..fbb26918 100644 --- a/cmd/param.go +++ b/cmd/param.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" + "os" "strings" "github.com/spf13/cobra" @@ -27,6 +28,7 @@ import ( const ( flagParamEnv = "env" flagParamComponent = "component" + flagParamNamespace = "namespace" ) var paramShortDesc = map[string]string{ @@ -44,6 +46,7 @@ func init() { paramSetCmd.PersistentFlags().String(flagParamEnv, "", "Specify environment to set parameters for") paramListCmd.PersistentFlags().String(flagParamEnv, "", "Specify environment to list parameters for") + paramListCmd.Flags().String(flagParamNamespace, "", "Specify namespace to list parameters for") paramDiffCmd.PersistentFlags().String(flagParamComponent, "", "Specify the component to diff against") } @@ -156,7 +159,12 @@ var paramListCmd = &cobra.Command{ return err } - c := kubecfg.NewParamListCmd(component, env) + nsName, err := flags.GetString(flagParamNamespace) + if err != nil { + return err + } + + c := kubecfg.NewParamListCmd(component, env, nsName) return c.Run(cmd.OutOrStdout()) }, @@ -196,6 +204,11 @@ var paramDiffCmd = &cobra.Command{ return fmt.Errorf("'param diff' takes exactly two arguments: the respective names of the environments being diffed") } + cwd, err := os.Getwd() + if err != nil { + return err + } + env1 := args[0] env2 := args[1] @@ -204,7 +217,7 @@ var paramDiffCmd = &cobra.Command{ return err } - c := kubecfg.NewParamDiffCmd(env1, env2, component) + c := kubecfg.NewParamDiffCmd(appFs, cwd, env1, env2, component) return c.Run(cmd.OutOrStdout()) }, diff --git a/cmd/pkg.go b/cmd/pkg.go index 68728cde..59b29778 100644 --- a/cmd/pkg.go +++ b/cmd/pkg.go @@ -242,7 +242,7 @@ var pkgListCmd = &cobra.Command{ return err } - app, err := manager.AppSpec() + app, err := manager.App() if err != nil { return err } @@ -254,14 +254,14 @@ var pkgListCmd = &cobra.Command{ strings.Repeat("=", len(nameHeader)), strings.Repeat("=", len(installedHeader))}, } - for name := range app.Registries { + for name := range app.Registries() { reg, _, err := manager.GetRegistry(name) if err != nil { return err } for libName := range reg.Libraries { - _, isInstalled := app.Libraries[libName] + _, isInstalled := app.Libraries()[libName] if isInstalled { rows = append(rows, []string{name, libName, installed}) } else { diff --git a/cmd/registry.go b/cmd/registry.go index 7d385272..a3af7786 100644 --- a/cmd/registry.go +++ b/cmd/registry.go @@ -82,7 +82,7 @@ var registryListCmd = &cobra.Command{ return err } - app, err := manager.AppSpec() + app, err := manager.App() if err != nil { return err } @@ -95,7 +95,7 @@ var registryListCmd = &cobra.Command{ strings.Repeat("=", len(uriHeader)), }, } - for name, regRef := range app.Registries { + for name, regRef := range app.Registries() { rows = append(rows, []string{name, regRef.Protocol, regRef.URI}) } @@ -141,12 +141,12 @@ var registryDescribeCmd = &cobra.Command{ return err } - app, err := manager.AppSpec() + app, err := manager.App() if err != nil { return err } - regRef, exists := app.GetRegistryRef(name) + regRef, exists := app.Registries()[name] if !exists { return fmt.Errorf("Registry '%s' doesn't exist", name) } diff --git a/cmd/root.go b/cmd/root.go index 9b22ec39..3151d146 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,6 +29,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/ksonnet/ksonnet/component" + "github.com/ksonnet/ksonnet/env" "github.com/ksonnet/ksonnet/metadata" "github.com/ksonnet/ksonnet/plugin" str "github.com/ksonnet/ksonnet/strings" @@ -326,9 +327,14 @@ func (te *cmdObjExpander) Expand() ([]*unstructured.Unstructured, error) { return nil, err } + app, err := manager.App() + if err != nil { + return nil, err + } + expander.FlagJpath = append([]string{string(vendorPath), string(libPath), string(envPath)}, expander.FlagJpath...) - namespacedComponentPaths, err := component.MakePathsByNamespace(te.config.fs, manager, te.config.cwd, te.config.env) + namespacedComponentPaths, err := component.MakePathsByNamespace(te.config.fs, app, te.config.cwd, te.config.env) if err != nil { return nil, errors.Wrap(err, "component paths") } @@ -453,28 +459,20 @@ func importParams(path string) string { return fmt.Sprintf(`%s=import "%s"`, metadata.ParamsExtCodeKey, path) } -func importEnv(manager metadata.Manager, env string) (string, error) { - app, err := manager.AppSpec() +func importEnv(manager metadata.Manager, envName string) (string, error) { + app, err := manager.App() if err != nil { return "", err } - spec, exists := app.GetEnvironmentSpec(env) - if !exists { - return "", fmt.Errorf("Environment '%s' does not exist in app.yaml", env) - } - - type EnvironmentSpec struct { - Server string `json:"server"` - Namespace string `json:"namespace"` + spec, err := app.Environment(envName) + if err != nil { + return "", fmt.Errorf("Environment '%s' does not exist in app.yaml", envName) } - toMarshal := &EnvironmentSpec{ - Server: spec.Destination.Server, - Namespace: spec.Destination.Namespace, - } + destination := env.NewDestination(spec.Destination.Server, spec.Destination.Namespace) - marshalled, err := json.Marshal(toMarshal) + marshalled, err := json.Marshal(&destination) if err != nil { return "", err } diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 00000000..eeaada44 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,62 @@ +// Copyright 2018 The ksonnet 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 cmd + +import ( + "github.com/ksonnet/ksonnet/actions" + "github.com/spf13/cobra" +) + +const ( + upgradeShortDesc = "Upgrade ks configuration" + flagUpgradeDryRun = "dry-run" +) + +var upgradeCmd = &cobra.Command{ + Use: "upgrade [--dry-run]", + Short: upgradeShortDesc, + Long: upgradeLong, + RunE: func(cmd *cobra.Command, args []string) error { + dryRun, err := cmd.Flags().GetBool(flagUpgradeDryRun) + if err != nil { + return err + } + + return actions.Upgrade(dryRun) + }, +} + +func init() { + RootCmd.AddCommand(upgradeCmd) + + upgradeCmd.Flags().Bool(flagUpgradeDryRun, false, "Dry-run upgrade process. Prints out changes.") +} + +const upgradeLong = ` +The upgrade command upgrades a ksonnet application to the latest version. + +### Syntax + + Example: + +# Upgrade ksonnet application in dry-run mode to see the changes to be performed by the +# upgrade process. +ks upgrade --dry-run + +# Upgrade ksonnet application. This will update app.yaml to apiVersion 0.1.0 +# and migrate environment spec.json files to ` + "`" + `app.yaml` + "`" + `. +ks upgrade +` diff --git a/component/component.go b/component/component.go index 4c3f6e5e..df0db014 100644 --- a/component/component.go +++ b/component/component.go @@ -77,6 +77,15 @@ type Namespace struct { fs afero.Fs } +// NewNamespace creates an an instance of Namespace. +func NewNamespace(fs afero.Fs, root, name string) Namespace { + return Namespace{ + Path: name, + root: root, + fs: 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) @@ -197,15 +206,9 @@ func isComponentDir(fs afero.Fs, path string) (bool, error) { 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) +func MakePathsByNamespace(fs afero.Fs, ksApp app.App, root, env string) (map[Namespace][]string, error) { + paths, err := MakePaths(fs, ksApp, root, env) if err != nil { return nil, err } @@ -230,8 +233,8 @@ func MakePathsByNamespace(fs afero.Fs, appSpecer AppSpecer, root, env string) (m } // 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) +func MakePaths(fs afero.Fs, ksApp app.App, root, env string) ([]string, error) { + cpl, err := newComponentPathLocator(fs, ksApp, env) if err != nil { return nil, errors.Wrap(err, "create component path locator") } @@ -244,22 +247,17 @@ type componentPathLocator struct { envSpec *app.EnvironmentSpec } -func newComponentPathLocator(fs afero.Fs, appSpecer AppSpecer, env string) (*componentPathLocator, error) { - if appSpecer == nil { - return nil, errors.New("appSpecer is nil") +func newComponentPathLocator(fs afero.Fs, ksApp app.App, env string) (*componentPathLocator, error) { + if ksApp == nil { + return nil, errors.New("app is nil") } if fs == nil { return nil, errors.New("fs is nil") } - appSpec, err := appSpecer.AppSpec() + envSpec, err := ksApp.Environment(env) 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) } diff --git a/component/component_test.go b/component/component_test.go index 30ee8fde..c7dfae0f 100644 --- a/component/component_test.go +++ b/component/component_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/require" "github.com/ksonnet/ksonnet/metadata/app" + "github.com/ksonnet/ksonnet/metadata/app/mocks" ) var ( @@ -48,21 +49,6 @@ var ( } ) -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) @@ -311,15 +297,13 @@ func TestMakePathsByNameSpace(t *testing.T) { Targets: tc.targets, } - appSpec := &app.Spec{ - Environments: app.EnvironmentSpecs{"default": envSpec}, - } - appSpecer := newStubAppSpecer(appSpec) + appMock := &mocks.App{} + appMock.On("Environment", "default").Return(envSpec, nil) root := "/" env := "default" - paths, err := MakePathsByNamespace(fs, appSpecer, root, env) + paths, err := MakePathsByNamespace(fs, appMock, root, env) if tc.isErr { require.Error(t, err) } else { @@ -394,15 +378,13 @@ func TestMakePaths(t *testing.T) { Targets: tc.targets, } - appSpec := &app.Spec{ - Environments: app.EnvironmentSpecs{"default": envSpec}, - } - appSpecer := newStubAppSpecer(appSpec) + appMock := &mocks.App{} + appMock.On("Environment", "default").Return(envSpec, nil) root := "/" env := "default" - paths, err := MakePaths(fs, appSpecer, root, env) + paths, err := MakePaths(fs, appMock, root, env) if tc.isErr { require.Error(t, err) } else { @@ -413,14 +395,14 @@ func TestMakePaths(t *testing.T) { } } -func TestMakePaths_invalid_appSpecer(t *testing.T) { +func TestMakePaths_invalid_app(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") + appMock := &mocks.App{} + _, err := MakePaths(nil, appMock, "/", "default") require.Error(t, err) } diff --git a/docs/cli-reference/ks.md b/docs/cli-reference/ks.md index 041bc8b5..3315d202 100644 --- a/docs/cli-reference/ks.md +++ b/docs/cli-reference/ks.md @@ -36,6 +36,7 @@ ks [flags] * [ks prototype](ks_prototype.md) - Instantiate, inspect, and get examples for ksonnet prototypes * [ks registry](ks_registry.md) - Manage registries for current project * [ks show](ks_show.md) - Show expanded manifests for a specific environment. +* [ks upgrade](ks_upgrade.md) - Upgrade ks configuration * [ks validate](ks_validate.md) - Check generated component manifests against the server's API * [ks version](ks_version.md) - Print version information for this ksonnet binary diff --git a/docs/cli-reference/ks_param_list.md b/docs/cli-reference/ks_param_list.md index 38b12d1a..390980ed 100644 --- a/docs/cli-reference/ks_param_list.md +++ b/docs/cli-reference/ks_param_list.md @@ -42,8 +42,9 @@ ks param list guestbook --env=dev ### Options ``` - --env string Specify environment to list parameters for - -h, --help help for list + --env string Specify environment to list parameters for + -h, --help help for list + --namespace string Specify namespace to list parameters for ``` ### Options inherited from parent commands diff --git a/docs/cli-reference/ks_upgrade.md b/docs/cli-reference/ks_upgrade.md new file mode 100644 index 00000000..a3d8202e --- /dev/null +++ b/docs/cli-reference/ks_upgrade.md @@ -0,0 +1,43 @@ +## ks upgrade + +Upgrade ks configuration + +### Synopsis + + +The upgrade command upgrades a ksonnet application to the latest version. + +### Syntax + + Example: + +# Upgrade ksonnet application in dry-run mode to see the changes to be performed by the +# upgrade process. +ks upgrade --dry-run + +# Upgrade ksonnet application. This will update app.yaml to apiVersion 0.1.0 +# and migrate environment spec.json files to `app.yaml`. +ks upgrade + + +``` +ks upgrade [--dry-run] [flags] +``` + +### Options + +``` + --dry-run Dry-run upgrade process. Prints out changes. + -h, --help help for upgrade +``` + +### Options inherited from parent commands + +``` + -v, --verbose count[=-1] Increase verbosity. May be given multiple times. +``` + +### SEE ALSO + +* [ks](ks.md) - Configure your application to deploy to a Kubernetes cluster + diff --git a/env/create.go b/env/create.go new file mode 100644 index 00000000..8a047fa3 --- /dev/null +++ b/env/create.go @@ -0,0 +1,124 @@ +package env + +import ( + "fmt" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/ksonnet/ksonnet/metadata/app" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +// CreateConfig is configuration for creating an environment. +type CreateConfig struct { + App app.App + Destination Destination + Fs afero.Fs + K8sSpecFlag string + Name string + RootPath string + + OverrideData []byte + ParamsData []byte +} + +// Create creates a new environment for the project. +func Create(config CreateConfig) error { + c, err := newCreator(config) + if err != nil { + return err + } + return c.Create() +} + +type creator struct { + CreateConfig +} + +func newCreator(config CreateConfig) (*creator, error) { + return &creator{ + CreateConfig: config, + }, nil +} + +func (c *creator) Create() error { + if c.environmentExists() { + return errors.Errorf("Could not create %q", c.Name) + } + + // ensure environment name does not contain punctuation + if !isValidName(c.Name) { + return fmt.Errorf("Environment name %q is not valid; must not contain punctuation, spaces, or begin or end with a slash", c.Name) + } + + log.Infof("Creating environment %q with namespace %q, pointing to cluster at address %q", + c.Name, c.Destination.Namespace(), c.Destination.Server()) + + envPath := filepath.Join(c.RootPath, app.EnvironmentDirName, c.Name) + err := c.Fs.MkdirAll(envPath, app.DefaultFolderPermissions) + if err != nil { + return err + } + + metadata := []struct { + path string + data []byte + }{ + { + // environment base override file + filepath.Join(envPath, envFileName), + c.OverrideData, + }, + { + // params file + filepath.Join(envPath, paramsFileName), + c.ParamsData, + }, + } + + for _, a := range metadata { + fileName := path.Base(a.path) + log.Debugf("Generating '%s', length: %d", fileName, len(a.data)) + if err = afero.WriteFile(c.Fs, a.path, a.data, app.DefaultFilePermissions); err != nil { + log.Debugf("Failed to write '%s'", fileName) + return err + } + } + + // update app.yaml + err = c.App.AddEnvironment(c.Name, c.K8sSpecFlag, &app.EnvironmentSpec{ + Path: c.Name, + Destination: &app.EnvironmentDestinationSpec{ + Server: c.Destination.Server(), + Namespace: c.Destination.Namespace(), + }, + }) + + return err +} + +func (c *creator) environmentExists() bool { + _, err := c.App.Environment(c.Name) + return err == nil +} + +// isValidName returns true if a name (e.g., for an environment) is valid. +// Broadly, this means 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) +} diff --git a/env/create_test.go b/env/create_test.go new file mode 100644 index 00000000..398edc8c --- /dev/null +++ b/env/create_test.go @@ -0,0 +1,40 @@ +package env + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/mock" + + "github.com/ksonnet/ksonnet/metadata/app/mocks" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestCreate(t *testing.T) { + withEnv(t, func(fs afero.Fs) { + appMock := &mocks.App{} + appMock.On("Environment", "newenv").Return(nil, errors.New("it does not exist")) + appMock.On( + "AddEnvironment", + "newenv", + "version:v1.8.7", + mock.AnythingOfType("*app.EnvironmentSpec"), + ).Return(nil) + + config := CreateConfig{ + App: appMock, + Fs: fs, + Destination: NewDestination("http://example.com", "default"), + RootPath: "/", + Name: "newenv", + K8sSpecFlag: "version:v1.8.7", + } + + err := Create(config) + require.NoError(t, err) + + checkExists(t, fs, "/environments/newenv/main.jsonnet") + checkExists(t, fs, "/environments/newenv/params.libsonnet") + }) +} diff --git a/env/delete.go b/env/delete.go new file mode 100644 index 00000000..4779b6e0 --- /dev/null +++ b/env/delete.go @@ -0,0 +1,63 @@ +package env + +import ( + "path/filepath" + + "github.com/ksonnet/ksonnet/metadata/app" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +// DeleteConfig is a configuration for deleting an environment. +type DeleteConfig struct { + App app.App + AppRoot string + Name string + Fs afero.Fs +} + +// Delete deletes an environment. +func Delete(config DeleteConfig) error { + d, err := newDeleter(config) + if err != nil { + return err + } + return d.Delete() +} + +type deleter struct { + DeleteConfig +} + +func newDeleter(config DeleteConfig) (*deleter, error) { + return &deleter{ + DeleteConfig: config, + }, nil +} + +func (d *deleter) Delete() error { + envPath, err := filepath.Abs(filepath.Join(d.AppRoot, envRoot, d.Name)) + if err != nil { + return err + } + + log.Infof("Deleting environment %q with metadata at path %q", d.Name, envPath) + + // Remove the directory and all files within the environment path. + if err = d.Fs.RemoveAll(envPath); err != nil { + // if err = d.cleanEmptyParentDirs(); err != nil { + log.Debugf("Failed to remove environment directory at path %q", envPath) + return err + } + + if err = d.App.RemoveEnvironment(d.Name); err != nil { + return err + } + + if err = cleanEmptyDirs(d.Fs, d.AppRoot); err != nil { + return err + } + + log.Infof("Successfully removed environment '%s'", d.Name) + return nil +} diff --git a/env/delete_test.go b/env/delete_test.go new file mode 100644 index 00000000..403cc507 --- /dev/null +++ b/env/delete_test.go @@ -0,0 +1,28 @@ +package env + +import ( + "testing" + + "github.com/ksonnet/ksonnet/metadata/app/mocks" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestDelete(t *testing.T) { + withEnv(t, func(fs afero.Fs) { + appMock := &mocks.App{} + appMock.On("RemoveEnvironment", "nested/env3").Return(nil) + + config := DeleteConfig{ + App: appMock, + Fs: fs, + Name: "nested/env3", + AppRoot: "/", + } + + err := Delete(config) + require.NoError(t, err) + + checkNotExists(t, fs, "/environments/nested") + }) +} diff --git a/env/destination.go b/env/destination.go new file mode 100644 index 00000000..ba2312e1 --- /dev/null +++ b/env/destination.go @@ -0,0 +1,48 @@ +package env + +import "encoding/json" + +const ( + // destDefaultNamespace is the default namespace name. + destDefaultNamespace = "default" +) + +// Destination contains destination information for a cluster. +type Destination struct { + server string + namespace string +} + +// NewDestination creates an instance of Destination. +func NewDestination(server, namespace string) Destination { + return Destination{ + server: server, + namespace: namespace, + } +} + +// MarshalJSON marshals a Destination to JSON. +func (d *Destination) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Server string `json:"server"` + Namespace string `json:"namespace"` + }{ + Server: d.Server(), + Namespace: d.Namespace(), + }) +} + +// Server is URL to the Kubernetes server that the cluster is running on. +func (d *Destination) Server() string { + return d.server +} + +// Namespace is the namespace of the Kubernetes server that targets should +// be deployed. +func (d *Destination) Namespace() string { + if d.namespace == "" { + return destDefaultNamespace + } + + return d.namespace +} diff --git a/env/env.go b/env/env.go new file mode 100644 index 00000000..ba1beec5 --- /dev/null +++ b/env/env.go @@ -0,0 +1,62 @@ +package env + +import ( + "github.com/ksonnet/ksonnet/metadata/app" +) + +const ( + // primary environment files. + envFileName = "main.jsonnet" + paramsFileName = "params.libsonnet" + + // envRoot is the name for the environment root. + envRoot = "environments" +) + +// Env represents a ksonnet environment. +type Env struct { + // Name is the environment name. + Name string + // KubernetesVersion is the version of Kubernetes for this environment. + KubernetesVersion string + // Destination is the cluster destination for this environment. + Destination Destination + // Targets are the component namespaces that will be installed. + Targets []string +} + +func envFromSpec(name string, envSpec *app.EnvironmentSpec) *Env { + return &Env{ + Name: name, + KubernetesVersion: envSpec.KubernetesVersion, + Destination: NewDestination(envSpec.Destination.Server, envSpec.Destination.Namespace), + Targets: envSpec.Targets, + } +} + +// List lists all environments for the current ksonnet application. +func List(ksApp app.App) (map[string]Env, error) { + envs := make(map[string]Env) + + specs, err := ksApp.Environments() + if err != nil { + return nil, err + } + + for name, spec := range specs { + env := envFromSpec(name, spec) + envs[name] = *env + } + + return envs, nil +} + +// Retrieve retrieves an environment by name. +func Retrieve(ksApp app.App, name string) (*Env, error) { + envSpec, err := ksApp.Environment(name) + if err != nil { + return nil, err + } + + return envFromSpec(name, envSpec), nil +} diff --git a/env/env_test.go b/env/env_test.go new file mode 100644 index 00000000..6a6e74f6 --- /dev/null +++ b/env/env_test.go @@ -0,0 +1,41 @@ +package env + +import ( + "testing" + + "github.com/ksonnet/ksonnet/metadata/app" + "github.com/ksonnet/ksonnet/metadata/app/mocks" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestList(t *testing.T) { + withEnv(t, func(fs afero.Fs) { + appMock := &mocks.App{} + + specEnvs := app.EnvironmentSpecs{ + "default": &app.EnvironmentSpec{ + Path: "default", + Destination: &app.EnvironmentDestinationSpec{ + Namespace: "default", + Server: "http://example.com", + }, + KubernetesVersion: "v1.8.7", + }, + } + appMock.On("Environments").Return(specEnvs, nil) + + envs, err := List(appMock) + require.NoError(t, err) + + expected := map[string]Env{ + "default": Env{ + KubernetesVersion: "v1.8.7", + Name: "default", + Destination: NewDestination("http://example.com", "default"), + }, + } + + require.Equal(t, expected, envs) + }) +} diff --git a/env/params.go b/env/params.go new file mode 100644 index 00000000..b17add27 --- /dev/null +++ b/env/params.go @@ -0,0 +1,103 @@ +package env + +import ( + "github.com/ksonnet/ksonnet/component" + "github.com/ksonnet/ksonnet/metadata/app" + param "github.com/ksonnet/ksonnet/metadata/params" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +// SetParamsConfig is config items for setting environment params. +type SetParamsConfig struct { + AppRoot string + Fs afero.Fs +} + +// SetParams sets params for an environment. +func SetParams(envName, component string, params param.Params, config SetParamsConfig) error { + exists, err := envExists(config.Fs, config.AppRoot, envName) + if err != nil { + return err + } + if !exists { + return errors.Errorf("Environment %q does not exist", envName) + } + + path := envPath(config.AppRoot, envName, paramsFileName) + + text, err := afero.ReadFile(config.Fs, path) + if err != nil { + return err + } + + appended, err := param.SetEnvironmentParams(component, string(text), params) + if err != nil { + return err + } + + err = afero.WriteFile(config.Fs, path, []byte(appended), app.DefaultFilePermissions) + if err != nil { + return err + } + + log.Debugf("Successfully set parameters for component %q at environment %q", component, envName) + return nil +} + +// GetParamsConfig is config items for getting environment params. +type GetParamsConfig struct { + AppRoot string + Fs afero.Fs +} + +// GetParams gets all parameters for an environment. +func GetParams(envName, nsName string, config GetParamsConfig) (map[string]param.Params, error) { + exists, err := envExists(config.Fs, config.AppRoot, envName) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.Errorf("Environment %q does not exist", envName) + } + + // Get the environment specific params + envParamsPath := envPath(config.AppRoot, envName, paramsFileName) + envParamsText, err := afero.ReadFile(config.Fs, envParamsPath) + if err != nil { + return nil, err + } + envParams, err := param.GetAllEnvironmentParams(string(envParamsText)) + if err != nil { + return nil, err + } + + // figure out what component we need + ns := component.NewNamespace(config.Fs, config.AppRoot, nsName) + componentParamsFile, err := afero.ReadFile(config.Fs, ns.ParamsPath()) + if err != nil { + return nil, err + } + + componentParams, err := param.GetAllComponentParams(string(componentParamsFile)) + if err != nil { + return nil, err + } + + return mergeParamMaps(componentParams, envParams), nil +} + +// TODO: move this to the consolidated params support namespace. +func mergeParamMaps(base, overrides map[string]param.Params) map[string]param.Params { + for component, params := range overrides { + if _, contains := base[component]; !contains { + base[component] = params + } else { + for k, v := range params { + base[component][k] = v + } + } + } + return base +} diff --git a/env/params_test.go b/env/params_test.go new file mode 100644 index 00000000..8b09b39c --- /dev/null +++ b/env/params_test.go @@ -0,0 +1,103 @@ +package env + +import ( + "reflect" + "testing" + + "github.com/ksonnet/ksonnet/metadata/params" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestSetParams(t *testing.T) { + withEnv(t, func(fs afero.Fs) { + config := SetParamsConfig{ + AppRoot: "/", + Fs: fs, + } + + p := params.Params{ + "foo": "bar", + } + + err := SetParams("env1", "component1", p, config) + require.NoError(t, err) + + compareOutput(t, fs, "updated-params.libsonnet", "/environments/env1/params.libsonnet") + }) +} + +func TestGetParams(t *testing.T) { + withEnv(t, func(fs afero.Fs) { + config := GetParamsConfig{ + AppRoot: "/", + Fs: fs, + } + + p, err := GetParams("env1", "", config) + require.NoError(t, err) + + expected := map[string]params.Params{ + "component1": params.Params{ + "foo": `"bar"`, + }, + } + + require.Equal(t, expected, p) + }) +} + +func TestMergeParamMaps(t *testing.T) { + tests := []struct { + base map[string]params.Params + overrides map[string]params.Params + expected map[string]params.Params + }{ + { + map[string]params.Params{ + "bar": params.Params{"replicas": "5"}, + }, + map[string]params.Params{ + "foo": params.Params{"name": `"foo"`, "replicas": "1"}, + }, + map[string]params.Params{ + "bar": params.Params{"replicas": "5"}, + "foo": params.Params{"name": `"foo"`, "replicas": "1"}, + }, + }, + { + map[string]params.Params{ + "bar": params.Params{"replicas": "5"}, + }, + map[string]params.Params{ + "bar": params.Params{"name": `"foo"`}, + }, + map[string]params.Params{ + "bar": params.Params{"name": `"foo"`, "replicas": "5"}, + }, + }, + { + map[string]params.Params{ + "bar": params.Params{"name": `"bar"`, "replicas": "5"}, + "foo": params.Params{"name": `"foo"`, "replicas": "4"}, + "baz": params.Params{"name": `"baz"`, "replicas": "3"}, + }, + map[string]params.Params{ + "foo": params.Params{"replicas": "1"}, + "baz": params.Params{"name": `"foobaz"`}, + }, + map[string]params.Params{ + "bar": params.Params{"name": `"bar"`, "replicas": "5"}, + "foo": params.Params{"name": `"foo"`, "replicas": "1"}, + "baz": params.Params{"name": `"foobaz"`, "replicas": "3"}, + }, + }, + } + + for _, s := range tests { + result := mergeParamMaps(s.base, s.overrides) + if !reflect.DeepEqual(s.expected, result) { + t.Errorf("Wrong merge\n expected:\n%v\n got:\n%v", s.expected, result) + } + } +} diff --git a/env/rename.go b/env/rename.go new file mode 100644 index 00000000..51880a51 --- /dev/null +++ b/env/rename.go @@ -0,0 +1,179 @@ +package env + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "github.com/ksonnet/ksonnet/metadata/app" +) + +// RenameConfig are options for renaming an environment. +type RenameConfig struct { + App app.App + AppRoot string + Fs afero.Fs +} + +// Rename renames an environment +func Rename(from, to string, config RenameConfig) error { + r, err := newRenamer(config) + if err != nil { + return err + } + return r.Rename(from, to) +} + +type renamer struct { + RenameConfig +} + +func newRenamer(config RenameConfig) (*renamer, error) { + return &renamer{ + RenameConfig: config, + }, nil +} + +func (r *renamer) Rename(from, to string) error { + if from == to || to == "" { + return nil + } + + if err := r.preflight(from, to); err != nil { + return err + } + + pathFrom := envPath(r.AppRoot, from) + pathTo := envPath(r.AppRoot, to) + + exists, err := afero.DirExists(r.Fs, pathFrom) + if err != nil { + return err + } + + if !exists { + return errors.Errorf("environment directory for %q does not exist", from) + } + + log.Infof("Setting environment name from %q to %q", from, to) + + current, err := Retrieve(r.App, from) + if err != nil { + return err + } + + update := &app.EnvironmentSpec{ + Destination: &app.EnvironmentDestinationSpec{ + Namespace: current.Destination.Namespace(), + Server: current.Destination.Server(), + }, + KubernetesVersion: current.KubernetesVersion, + Targets: current.Targets, + Path: to, + } + + k8sSpecFlag := fmt.Sprintf("version:%s", current.KubernetesVersion) + + if err = r.App.AddEnvironment(from, k8sSpecFlag, update); err != nil { + return err + } + + if err = moveDir(r.Fs, pathFrom, pathTo); err != nil { + return err + } + + if err = cleanEmptyDirs(r.Fs, r.AppRoot); err != nil { + return errors.Wrap(err, "clean empty directories") + } + + log.Infof("Successfully moved %q to %q", from, to) + return nil +} + +func (r *renamer) preflight(from, to string) error { + if !isValidName(to) { + return fmt.Errorf("Environment name %q is not valid; must not contain punctuation, spaces, or begin or end with a slash", + to) + } + + exists, err := envExists(r.Fs, r.AppRoot, to) + if err != nil { + log.Debugf("Failed to check whether environment %q already exists", to) + return err + } + if exists { + return fmt.Errorf("Failed to update %q; environment %q exists", from, to) + } + + return nil +} + +func envExists(fs afero.Fs, appRoot, name string) (bool, error) { + path := envPath(appRoot, name, envFileName) + return afero.Exists(fs, path) +} + +func moveDir(fs afero.Fs, src, dest string) error { + exists, err := afero.DirExists(fs, dest) + if err != nil { + return err + } + + if !exists { + if err = fs.MkdirAll(dest, app.DefaultFolderPermissions); err != nil { + return errors.Wrapf(err, "unable to create destination %q", dest) + } + } + + fis, err := afero.ReadDir(fs, src) + if err != nil { + return err + } + + for _, fi := range fis { + if fi.IsDir() && fi.Name() != ".metadata" { + continue + } + + srcPath := filepath.Join(src, fi.Name()) + destPath := filepath.Join(dest, fi.Name()) + + if err = fs.Rename(srcPath, destPath); err != nil { + return err + } + } + + return nil +} + +func envPath(root, name string, subPath ...string) string { + return filepath.Join(append([]string{root, envRoot, name}, subPath...)...) +} + +func cleanEmptyDirs(fs afero.Fs, root string) error { + log.Debug("Removing empty environment directories, if any") + envPath := filepath.Join(root, envRoot) + return afero.Walk(fs, envPath, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return nil + } + + if !fi.IsDir() { + return nil + } + + isEmpty, err := afero.IsEmpty(fs, path) + if err != nil { + log.Debugf("Failed to check whether directory at path %q is empty", path) + return err + } + if isEmpty { + return fs.RemoveAll(path) + } + return nil + }) +} diff --git a/env/rename_test.go b/env/rename_test.go new file mode 100644 index 00000000..a490275d --- /dev/null +++ b/env/rename_test.go @@ -0,0 +1,46 @@ +package env + +import ( + "testing" + + "github.com/stretchr/testify/mock" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "github.com/ksonnet/ksonnet/metadata/app" + "github.com/ksonnet/ksonnet/metadata/app/mocks" +) + +func TestRename(t *testing.T) { + withEnv(t, func(fs afero.Fs) { + appMock := &mocks.App{} + + envSpec := &app.EnvironmentSpec{ + Path: "env1", + Destination: &app.EnvironmentDestinationSpec{Namespace: "default", Server: "http://example.com"}, + KubernetesVersion: "v1.9.2", + } + appMock.On("Environment", "env1").Return(envSpec, nil) + + appMock.On( + "AddEnvironment", + "env1", + "version:v1.9.2", + mock.AnythingOfType("*app.EnvironmentSpec")).Return(nil) + + config := RenameConfig{ + App: appMock, + AppRoot: "/", + Fs: fs, + } + + checkExists(t, fs, "/environments/env1") + + err := Rename("env1", "env1-updated", config) + require.NoError(t, err) + + checkNotExists(t, fs, "/environments/env1") + checkExists(t, fs, "/environments/env1-updated/main.jsonnet") + }) +} diff --git a/env/testdata/app.yaml b/env/testdata/app.yaml new file mode 100644 index 00000000..a7e78db2 --- /dev/null +++ b/env/testdata/app.yaml @@ -0,0 +1,28 @@ +apiVersion: 0.1.0 +environments: + env1: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: v1.7.0 + path: env1 + env2: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: "" + path: env2 + nested/env3: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: "" + path: nest/env3 +kind: ksonnet.io/app +name: test-get-envs +registries: + incubator: + gitVersion: null + protocol: "" + uri: "" +version: 0.0.1 \ No newline at end of file diff --git a/env/testdata/component-params.libsonnet b/env/testdata/component-params.libsonnet new file mode 100644 index 00000000..493125a0 --- /dev/null +++ b/env/testdata/component-params.libsonnet @@ -0,0 +1,10 @@ +{ + 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 + }, +} \ No newline at end of file diff --git a/env/testdata/main.jsonnet b/env/testdata/main.jsonnet new file mode 100644 index 00000000..2dae67b2 --- /dev/null +++ b/env/testdata/main.jsonnet @@ -0,0 +1 @@ +// main.jsonnet \ No newline at end of file diff --git a/env/testdata/params.libsonnet b/env/testdata/params.libsonnet new file mode 100644 index 00000000..be827610 --- /dev/null +++ b/env/testdata/params.libsonnet @@ -0,0 +1,13 @@ +local params = import "../../components/params.libsonnet"; +params + { + components +: { + // Insert component parameter overrides here. Ex: + // guestbook +: { + // name: "guestbook-dev", + // replicas: params.global.replicas, + // }, + component1 +: { + foo: "bar", + }, + }, +} diff --git a/env/testdata/updated-params.libsonnet b/env/testdata/updated-params.libsonnet new file mode 100644 index 00000000..563086b3 --- /dev/null +++ b/env/testdata/updated-params.libsonnet @@ -0,0 +1,13 @@ +local params = import "../../components/params.libsonnet"; +params + { + components +: { + // Insert component parameter overrides here. Ex: + // guestbook +: { + // name: "guestbook-dev", + // replicas: params.global.replicas, + // }, + component1 +: { + foo: bar, + }, + }, +} diff --git a/env/util_test.go b/env/util_test.go new file mode 100644 index 00000000..fb242094 --- /dev/null +++ b/env/util_test.go @@ -0,0 +1,83 @@ +package env + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/ksonnet/ksonnet/metadata/app" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func stageFile(t *testing.T, fs afero.Fs, src, dest string) { + in := filepath.Join("testdata", src) + + b, err := ioutil.ReadFile(in) + require.NoError(t, err) + + dir := filepath.Dir(dest) + err = fs.MkdirAll(dir, 0755) + require.NoError(t, err) + + err = afero.WriteFile(fs, dest, b, 0644) + require.NoError(t, err) +} + +func withEnv(t *testing.T, fn func(afero.Fs)) { + tmpDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // NOTE: using an os fs here because afero doesn't handle renames in the memmap version + fs := afero.NewBasePathFs(afero.NewOsFs(), tmpDir) + stageFile(t, fs, "app.yaml", "/app.yaml") + + dirs := []string{ + "env1", + "env2", + "nest/env3", + } + + for _, dir := range dirs { + path := filepath.Join("/", envRoot, dir) + err := fs.MkdirAll(path, app.DefaultFolderPermissions) + require.NoError(t, err) + + mainPath := filepath.Join(path, "main.jsonnet") + stageFile(t, fs, "main.jsonnet", mainPath) + + paramsPath := filepath.Join(path, "params.libsonnet") + stageFile(t, fs, "params.libsonnet", paramsPath) + } + + componentParamsPath := filepath.Join("/", "components", "params.libsonnet") + stageFile(t, fs, "component-params.libsonnet", componentParamsPath) + + fn(fs) +} + +func checkExists(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.Exists(fs, path) + require.NoError(t, err) + + require.True(t, exists, "%q should exist", path) +} + +func checkNotExists(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.Exists(fs, path) + require.NoError(t, err) + + require.False(t, exists, "%q should not exist", path) +} + +func compareOutput(t *testing.T, fs afero.Fs, expected, got string) { + gotData, err := afero.ReadFile(fs, got) + require.NoError(t, err) + + expectedData, err := ioutil.ReadFile(filepath.Join("testdata", expected)) + require.NoError(t, err) + + require.Equal(t, string(expectedData), string(gotData)) +} diff --git a/generator/ksonnet.go b/generator/ksonnet.go index 647fa781..2933d376 100644 --- a/generator/ksonnet.go +++ b/generator/ksonnet.go @@ -1,16 +1,22 @@ package generator import ( + "encoding/json" "io/ioutil" "os" + "strings" + + "github.com/blang/semver" + "github.com/ksonnet/ksonnet-lib/ksonnet-gen/kubespec" - "github.com/davecgh/go-spew/spew" "github.com/ksonnet/ksonnet-lib/ksonnet-gen/ksonnet" ) var ( // ksonnetEmitter is the function which emits the ksonnet standard library. ksonnetEmitter = ksonnet.GenerateLib + // revision is the current revision of ksonnet based on the git ref. + revision string ) // KsonnetLib is the ksonnet standard library for a version of swagger. @@ -35,6 +41,16 @@ func Ksonnet(swaggerData []byte) (*KsonnetLib, error) { defer os.Remove(f.Name()) + var apiSpec kubespec.APISpec + if err = json.Unmarshal(swaggerData, &apiSpec); err != nil { + return nil, err + } + + ver, err := semver.Make(strings.TrimPrefix(apiSpec.Info.Version, "v")) + if err != nil { + return nil, err + } + _, err = f.Write(swaggerData) if err != nil { return nil, err @@ -44,9 +60,16 @@ func Ksonnet(swaggerData []byte) (*KsonnetLib, error) { return nil, err } - spew.Dump("---", f.Name(), ksonnetEmitter) + astMin := semver.MustParse("1.8.0") + if ver.LT(astMin) { + return textBuilder(&apiSpec, swaggerData) + } + + return astBuilder(f.Name(), swaggerData) +} - lib, err := ksonnetEmitter(f.Name()) +func astBuilder(input string, swaggerData []byte) (*KsonnetLib, error) { + lib, err := ksonnetEmitter(input) if err != nil { return nil, err } @@ -60,3 +83,19 @@ func Ksonnet(swaggerData []byte) (*KsonnetLib, error) { return kl, nil } + +func textBuilder(apiSpec *kubespec.APISpec, swaggerData []byte) (*KsonnetLib, error) { + bK, bK8s, err := ksonnet.Emit(apiSpec, &revision, &revision) + if err != nil { + return nil, err + } + + kl := &KsonnetLib{ + K: bK, + K8s: bK8s, + Swagger: swaggerData, + Version: apiSpec.Info.Version, + } + + return kl, nil +} diff --git a/generator/ksonnet_test.go b/generator/ksonnet_test.go index 91b0dd80..c2cb990d 100644 --- a/generator/ksonnet_test.go +++ b/generator/ksonnet_test.go @@ -18,7 +18,7 @@ func TestKsonnet(t *testing.T) { lib = []byte("k8s") successfulEmit = func(string) (*ksonnet.Lib, error) { return &ksonnet.Lib{ - Version: "v1.7.0", + Version: "v1.8.0", K8s: lib, Extensions: ext, }, nil @@ -26,7 +26,7 @@ func TestKsonnet(t *testing.T) { failureEmit = func(string) (*ksonnet.Lib, error) { return nil, errors.New("failure") } - v170swagger = []byte(`{"info":{"version":"v1.7.0"}}`) + v180swagger = []byte(`{"info":{"version":"v1.8.0"}}`) ) cases := []struct { @@ -39,8 +39,8 @@ func TestKsonnet(t *testing.T) { { name: "valid swagger", emitter: successfulEmit, - swaggerData: v170swagger, - version: "v1.7.0", + swaggerData: v180swagger, + version: "v1.8.0", }, { name: "invalid swagger", @@ -51,7 +51,7 @@ func TestKsonnet(t *testing.T) { { name: "emitter error", emitter: failureEmit, - swaggerData: v170swagger, + swaggerData: v180swagger, isErr: true, }, } diff --git a/integration/integration_suite_test.go b/integration/integration_suite_test.go index f44a534f..363b342f 100644 --- a/integration/integration_suite_test.go +++ b/integration/integration_suite_test.go @@ -3,7 +3,6 @@ package integration import ( - "github.com/ksonnet/ksonnet/metadata/app" "flag" "fmt" "io" @@ -12,11 +11,13 @@ import ( "path" "testing" + "github.com/ksonnet/ksonnet/metadata/app" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apimachinery/registered" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" - "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apimachinery/registered" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -90,8 +91,8 @@ func containsString(haystack []string, needle string) bool { func runKsonnetWith(flags []string, host, ns string) error { spec := app.Spec{ - Version: "0.0.1", - APIVersion: "0.0.1", + Version: "0.0.1", + APIVersion: "0.1.0", Environments: app.EnvironmentSpecs{ "default": &app.EnvironmentSpec{ Destination: &app.EnvironmentDestinationSpec{ diff --git a/metadata/app.go b/metadata/app.go deleted file mode 100644 index f8614de0..00000000 --- a/metadata/app.go +++ /dev/null @@ -1,48 +0,0 @@ -package metadata - -import ( - "github.com/ksonnet/ksonnet/metadata/app" - "github.com/spf13/afero" -) - -// AppSpec will return the specification for a ksonnet application -// (typically stored in `app.yaml`) -func (m *manager) AppSpec() (*app.Spec, error) { - bytes, err := afero.ReadFile(m.appFS, string(m.appYAMLPath)) - if err != nil { - return nil, err - } - - schema, err := app.Unmarshal(bytes) - if err != nil { - return nil, err - } - - if schema.Contributors == nil { - schema.Contributors = app.ContributorSpecs{} - } - - if schema.Registries == nil { - schema.Registries = app.RegistryRefSpecs{} - } - - if schema.Libraries == nil { - schema.Libraries = app.LibraryRefSpecs{} - } - - if schema.Environments == nil { - schema.Environments = app.EnvironmentSpecs{} - } - - return schema, nil -} - -// WriteAppSpec writes the provided spec to the app.yaml file. -func (m *manager) WriteAppSpec(appSpec *app.Spec) error { - appSpecData, err := appSpec.Marshal() - if err != nil { - return err - } - - return afero.WriteFile(m.appFS, string(m.appYAMLPath), appSpecData, defaultFilePermissions) -} diff --git a/metadata/app/app.go b/metadata/app/app.go new file mode 100644 index 00000000..7e964470 --- /dev/null +++ b/metadata/app/app.go @@ -0,0 +1,80 @@ +package app + +import ( + "os" + "path/filepath" + + "github.com/ksonnet/ksonnet/metadata/lib" + "github.com/pkg/errors" + "github.com/spf13/afero" +) + +const ( + + // appYamlName is the name for the app specification. + appYamlName = "app.yaml" + + // EnvironmentDirName is the directory name for environments. + EnvironmentDirName = "environments" + + // LibDirName is the directory name for libraries. + LibDirName = "lib" +) + +var ( + // DefaultFilePermissions are the default permissions for a file. + DefaultFilePermissions = os.FileMode(0644) + // DefaultFolderPermissions are the default permissions for a folder. + DefaultFolderPermissions = os.FileMode(0755) + + // LibUpdater updates ksonnet lib versions. + LibUpdater = updateLibData +) + +// App is a ksonnet application. +type App interface { + AddEnvironment(name, k8sSpecFlag string, spec *EnvironmentSpec) error + Environment(name string) (*EnvironmentSpec, error) + Environments() (EnvironmentSpecs, error) + Libraries() LibraryRefSpecs + LibPath(envName string) (string, error) + Init() error + Registries() RegistryRefSpecs + RemoveEnvironment(name string) error + Upgrade(dryRun bool) error +} + +// Load loads the application configuration. +func Load(fs afero.Fs, appRoot string) (App, error) { + spec, err := Read(fs, appRoot) + if err != nil { + return nil, err + } + + switch spec.APIVersion { + default: + return nil, errors.Errorf("unknown apiVersion %q in %s", spec.APIVersion, appYamlName) + case "0.0.1": + return NewApp001(fs, appRoot) + case "0.1.0": + return NewApp010(fs, appRoot) + } +} + +func updateLibData(fs afero.Fs, k8sSpecFlag string, libPath string, useVersionPath bool) error { + lm, err := lib.NewManager(k8sSpecFlag, fs, libPath) + if err != nil { + return err + } + + return lm.GenerateLibData(useVersionPath) +} + +func app010LibPath(root string) string { + return filepath.Join(root, LibDirName) +} + +// StubUpdateLibData always returns no error. +func StubUpdateLibData(fs afero.Fs, k8sSpecFlag string, libPath string, useVersionPath bool) error { + return nil +} diff --git a/metadata/app/app001.go b/metadata/app/app001.go new file mode 100644 index 00000000..528f8ba7 --- /dev/null +++ b/metadata/app/app001.go @@ -0,0 +1,272 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/ksonnet/ksonnet/metadata/lib" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +const ( + // app001specJSON is the name for environment schema + app001specJSON = "spec.json" +) + +// App001 is a ksonnet 0.0.1 application. +type App001 struct { + spec *Spec + root string + fs afero.Fs + out io.Writer +} + +var _ App = (*App001)(nil) + +// NewApp001 creates an App001 instance. +func NewApp001(fs afero.Fs, root string) (*App001, error) { + spec, err := Read(fs, root) + if err != nil { + return nil, err + } + + return &App001{ + spec: spec, + fs: fs, + root: root, + out: os.Stdout, + }, nil +} + +// Init initializes the App. +func (a *App001) Init() error { + msg := "Your application's apiVersion is below 0.1.0. In order to use all ks features, you " + + "can upgrade your application using `ks upgrade`." + log.Warn(msg) + + return nil +} + +// AddEnvironment adds an environment spec to the app spec. If the spec already exists, +// it is overwritten. +func (a *App001) AddEnvironment(name, k8sSpecFlag string, spec *EnvironmentSpec) error { + envPath := filepath.Join(a.root, EnvironmentDirName, name) + if err := a.fs.MkdirAll(envPath, DefaultFolderPermissions); err != nil { + return err + } + + specPath := filepath.Join(envPath, app001specJSON) + + b, err := json.Marshal(spec.Destination) + if err != nil { + return err + } + + if err = afero.WriteFile(a.fs, specPath, b, DefaultFilePermissions); err != nil { + return err + } + + return LibUpdater(a.fs, k8sSpecFlag, a.appLibPath(name), false) +} + +// Registries returns application registries. +func (a *App001) Registries() RegistryRefSpecs { + return a.spec.Registries +} + +// Libraries returns application libraries. +func (a *App001) Libraries() LibraryRefSpecs { + return a.spec.Libraries +} + +// Environment returns the spec for an environment. In 0.1.0, the file lives in +// /environments/name/spec.json. +func (a *App001) Environment(name string) (*EnvironmentSpec, error) { + path := filepath.Join(a.root, EnvironmentDirName, name, app001specJSON) + return read001EnvSpec(a.fs, name, path) +} + +// Environments returns specs for all environments. In 0.1.0, the environment spec +// lives in spec.json files. +func (a *App001) Environments() (EnvironmentSpecs, error) { + specs := EnvironmentSpecs{} + + root := filepath.Join(a.root, EnvironmentDirName) + + err := afero.Walk(a.fs, root, func(path string, fi os.FileInfo, err error) error { + if fi.IsDir() { + return nil + } + + if fi.Name() == app001specJSON { + dir := filepath.Dir(path) + envName := strings.TrimPrefix(dir, root+"/") + spec, err := read001EnvSpec(a.fs, envName, path) + if err != nil { + return err + } + + specs[envName] = spec + } + + return nil + }) + if err != nil { + return nil, err + } + + return specs, nil +} + +type k8sSchema struct { + Info struct { + Version string `json:"version,omitempty"` + } `json:"info,omitempty"` +} + +func read001EnvSpec(fs afero.Fs, name, path string) (*EnvironmentSpec, error) { + b, err := afero.ReadFile(fs, path) + if err != nil { + return nil, err + } + + var s EnvironmentDestinationSpec + if err = json.Unmarshal(b, &s); err != nil { + return nil, err + } + + if s.Namespace == "" { + s.Namespace = "default" + } + + envPath := filepath.Dir(path) + swaggerPath := filepath.Join(envPath, ".metadata", "swagger.json") + + b, err = afero.ReadFile(fs, swaggerPath) + if err != nil { + return nil, err + } + + var ks k8sSchema + if err = json.Unmarshal(b, &ks); err != nil { + return nil, err + } + + if ks.Info.Version == "" { + return nil, errors.New("unable to determine environment Kubernetes version") + } + + spec := EnvironmentSpec{ + Path: name, + Destination: &s, + KubernetesVersion: ks.Info.Version, + } + + return &spec, nil +} + +// RemoveEnvironment removes an environment. +func (a *App001) RemoveEnvironment(envName string) error { + return nil +} + +// Upgrade upgrades the app to the latest apiVersion. +func (a *App001) Upgrade(dryRun bool) error { + if err := a.load(); err != nil { + return err + } + + if dryRun { + fmt.Fprintf(a.out, "\n[dry run] Upgrading application settings from version 0.0.1 to to 0.1.0.\n") + } + + envs, err := a.Environments() + if err != nil { + return err + } + + if dryRun { + fmt.Fprintf(a.out, "[dry run] Converting 0.0.1 environments to 0.1.0a:\n") + } + for _, env := range envs { + a.convertEnvironment(env.Path, dryRun) + } + + a.spec.APIVersion = "0.1.0" + + if dryRun { + data, err := a.spec.Marshal() + if err != nil { + return err + } + + fmt.Fprintf(a.out, "\n[dry run] Upgraded app.yaml:\n%s\n", string(data)) + fmt.Fprintf(a.out, "[dry run] You can preform the migration by running `ks upgrade`.\n") + return nil + } + + return a.save() +} + +func (a *App001) convertEnvironment(envName string, dryRun bool) error { + path := filepath.Join(a.root, EnvironmentDirName, envName, "spec.json") + env, err := read001EnvSpec(a.fs, envName, path) + if err != nil { + return err + } + + a.spec.Environments[envName] = env + + if dryRun { + fmt.Fprintf(a.out, "[dry run]\t* adding the environment description in environment `%s to `app.yaml`.\n", + envName) + return nil + } + + if err = a.fs.Remove(path); err != nil { + return err + } + + k8sSpecFlag := fmt.Sprintf("version:%s", env.KubernetesVersion) + return LibUpdater(a.fs, k8sSpecFlag, app010LibPath(a.root), true) +} + +func (a *App001) appLibPath(envName string) string { + return filepath.Join(a.root, EnvironmentDirName, envName, ".metadata") +} + +func (a *App001) save() error { + return Write(a.fs, a.root, a.spec) +} + +func (a *App001) load() error { + spec, err := Read(a.fs, a.root) + if err != nil { + return err + } + + a.spec = spec + return nil +} + +// LibPath returns the lib path for an env environment. +func (a *App001) LibPath(envName string) (string, error) { + env, err := a.Environment(envName) + if err != nil { + return "", err + } + + ver := fmt.Sprintf("version:%s", env.KubernetesVersion) + lm, err := lib.NewManager(ver, a.fs, a.appLibPath(envName)) + if err != nil { + return "", err + } + + return lm.GetLibPath(false) +} diff --git a/metadata/app/app001_test.go b/metadata/app/app001_test.go new file mode 100644 index 00000000..d74d348e --- /dev/null +++ b/metadata/app/app001_test.go @@ -0,0 +1,204 @@ +package app + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestApp001_Environments(t *testing.T) { + withApp001Fs(t, "app001_app.yaml", func(fs afero.Fs) { + app, err := NewApp001(fs, "/") + require.NoError(t, err) + + expected := EnvironmentSpecs{ + "default": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + KubernetesVersion: "v1.7.0", + Path: "default", + }, + "us-east/test": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + KubernetesVersion: "v1.7.0", + Path: "us-east/test", + }, + "us-west/test": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + KubernetesVersion: "v1.7.0", + Path: "us-west/test", + }, + "us-west/prod": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + KubernetesVersion: "v1.7.0", + Path: "us-west/prod", + }, + } + envs, err := app.Environments() + require.NoError(t, err) + + require.Equal(t, expected, envs) + }) +} + +func TestApp001_Environment(t *testing.T) { + cases := []struct { + name string + envName string + isErr bool + }{ + { + name: "existing env", + envName: "us-east/test", + }, + { + name: "invalid env", + envName: "missing", + isErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withApp001Fs(t, "app001_app.yaml", func(fs afero.Fs) { + app, err := NewApp001(fs, "/") + require.NoError(t, err) + + spec, err := app.Environment(tc.envName) + if tc.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.envName, spec.Path) + } + }) + }) + } +} + +func TestApp001_AddEnvironment(t *testing.T) { + withApp001Fs(t, "app001_app.yaml", func(fs afero.Fs) { + app, err := NewApp001(fs, "/") + require.NoError(t, err) + + newEnv := &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + Path: "us-west/qa", + } + + k8sSpecFlag := "version:v1.8.7" + err = app.AddEnvironment("us-west/qa", k8sSpecFlag, newEnv) + require.NoError(t, err) + + _, err = app.Environment("us-west/qa") + require.NoError(t, err) + }) +} + +func TestApp001_Upgrade_dryrun(t *testing.T) { + withApp001Fs(t, "app001_app.yaml", func(fs afero.Fs) { + app, err := NewApp001(fs, "/") + require.NoError(t, err) + + var buf bytes.Buffer + app.out = &buf + + err = app.Upgrade(true) + require.NoError(t, err) + + expected, err := ioutil.ReadFile("testdata/upgrade001.txt") + require.NoError(t, err) + + require.Equal(t, string(expected), buf.String()) + }) +} + +func TestApp001_Upgrade(t *testing.T) { + withApp001Fs(t, "app001_app.yaml", func(fs afero.Fs) { + app, err := NewApp001(fs, "/") + require.NoError(t, err) + + var buf bytes.Buffer + app.out = &buf + + err = app.Upgrade(false) + require.NoError(t, err) + + root := filepath.Join(app.root, EnvironmentDirName) + var foundSpec bool + err = afero.Walk(fs, root, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + if fi.IsDir() { + return nil + } + + if fi.Name() == "spec.json" { + foundSpec = true + } + return nil + }) + + require.NoError(t, err) + require.False(t, foundSpec) + }) +} + +func withApp001Fs(t *testing.T, appName string, fn func(fs afero.Fs)) { + ogLibUpdater := LibUpdater + LibUpdater = func(fs afero.Fs, k8sSpecFlag string, libPath string, useVersionPath bool) error { + path := filepath.Join(libPath, "swagger.json") + stageFile(t, fs, "swagger.json", path) + return nil + } + + defer func() { + LibUpdater = ogLibUpdater + }() + + fs := afero.NewMemMapFs() + + envDirs := []string{ + "default", + "us-east/test", + "us-west/test", + "us-west/prod", + } + + for _, dir := range envDirs { + path := filepath.Join("/environments", dir) + err := fs.MkdirAll(path, DefaultFolderPermissions) + require.NoError(t, err) + + specPath := filepath.Join(path, "spec.json") + stageFile(t, fs, "spec.json", specPath) + + swaggerPath := filepath.Join(path, ".metadata", "swagger.json") + stageFile(t, fs, "swagger.json", swaggerPath) + } + + stageFile(t, fs, appName, "/app.yaml") + + fn(fs) +} diff --git a/metadata/app/app010.go b/metadata/app/app010.go new file mode 100644 index 00000000..0cf90da6 --- /dev/null +++ b/metadata/app/app010.go @@ -0,0 +1,169 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/ksonnet/ksonnet/metadata/lib" + + "github.com/pkg/errors" + "github.com/spf13/afero" +) + +// App010 is a ksonnet 0.1.0 application. +type App010 struct { + spec *Spec + root string + fs afero.Fs +} + +var _ App = (*App010)(nil) + +// NewApp010 creates an App010 instance. +func NewApp010(fs afero.Fs, root string) (*App010, error) { + a := &App010{ + fs: fs, + root: root, + } + + if err := a.load(); err != nil { + return nil, err + } + + return a, nil +} + +// Init initializes the App. +func (a *App010) Init() error { + // check to see if there are spec.json files. + + legacyEnvs, err := a.findLegacySpec() + if err != nil { + return err + } + + if len(legacyEnvs) == 0 { + return nil + } + + msg := "Your application's apiVersion is 0.1.0, but legacy environment declarations " + + "where found in environments: %s. In order to proceed, you will have to run `ks upgrade` to " + + "upgrade your application. <see url>" + + return errors.Errorf(msg, strings.Join(legacyEnvs, ", ")) +} + +func (a *App010) findLegacySpec() ([]string, error) { + var found []string + + envPath := filepath.Join(a.root, EnvironmentDirName) + err := afero.Walk(a.fs, envPath, func(path string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if fi.IsDir() { + return nil + } + + if fi.Name() == app001specJSON { + envName := strings.TrimPrefix(path, envPath+"/") + envName = strings.TrimSuffix(envName, "/"+app001specJSON) + found = append(found, envName) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return found, nil +} + +// AddEnvironment adds an environment spec to the app spec. If the spec already exists, +// it is overwritten. +func (a *App010) AddEnvironment(name, k8sSpecFlag string, spec *EnvironmentSpec) error { + if err := a.load(); err != nil { + return err + } + + a.spec.Environments[name] = spec + + if err := LibUpdater(a.fs, k8sSpecFlag, app010LibPath(a.root), true); err != nil { + return err + } + + return a.save() +} + +// Registries returns application registries. +func (a *App010) Registries() RegistryRefSpecs { + return a.spec.Registries +} + +// Libraries returns application libraries. +func (a *App010) Libraries() LibraryRefSpecs { + return a.spec.Libraries +} + +// Environment returns the spec for an environment. +func (a *App010) Environment(name string) (*EnvironmentSpec, error) { + s, ok := a.spec.Environments[name] + if !ok { + return nil, errors.Errorf("environment %q was not found", name) + } + + return s, nil +} + +// Environments returns all environment specs. +func (a *App010) Environments() (EnvironmentSpecs, error) { + return a.spec.Environments, nil +} + +// RemoveEnvironment removes an environment. +func (a *App010) RemoveEnvironment(envName string) error { + if err := a.load(); err != nil { + return err + } + delete(a.spec.Environments, envName) + return a.save() +} + +func (a *App010) save() error { + return Write(a.fs, a.root, a.spec) +} + +func (a *App010) load() error { + spec, err := Read(a.fs, a.root) + if err != nil { + return err + } + + a.spec = spec + return nil +} + +// Upgrade upgrades the app to the latest apiVersion. +func (a *App010) Upgrade(dryRun bool) error { + return nil +} + +// LibPath returns the lib path for an env environment. +func (a *App010) LibPath(envName string) (string, error) { + env, err := a.Environment(envName) + if err != nil { + return "", err + } + + ver := fmt.Sprintf("version:%s", env.KubernetesVersion) + lm, err := lib.NewManager(ver, a.fs, app010LibPath(a.root)) + if err != nil { + return "", err + } + + return lm.GetLibPath(true) +} diff --git a/metadata/app/app010_test.go b/metadata/app/app010_test.go new file mode 100644 index 00000000..138ead17 --- /dev/null +++ b/metadata/app/app010_test.go @@ -0,0 +1,168 @@ +package app + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestApp0101_Environments(t *testing.T) { + withApp010Fs(t, "app010_app.yaml", func(fs afero.Fs) { + app, err := NewApp010(fs, "/") + require.NoError(t, err) + + expected := EnvironmentSpecs{ + "default": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + KubernetesVersion: "v1.7.0", + Path: "default", + }, + "us-east/test": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + Path: "us-east/test", + }, + "us-west/test": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + Path: "us-west/test", + }, + "us-west/prod": &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + Path: "us-west/prod", + }, + } + envs, err := app.Environments() + require.NoError(t, err) + + require.Equal(t, expected, envs) + }) +} + +func TestApp010_Environment(t *testing.T) { + cases := []struct { + name string + envName string + isErr bool + }{ + { + name: "existing env", + envName: "us-east/test", + }, + { + name: "invalid env", + envName: "missing", + isErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withApp010Fs(t, "app010_app.yaml", func(fs afero.Fs) { + app, err := NewApp010(fs, "/") + require.NoError(t, err) + + spec, err := app.Environment(tc.envName) + if tc.isErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.envName, spec.Path) + } + }) + }) + } +} + +func TestApp010_AddEnvironment(t *testing.T) { + withApp010Fs(t, "app010_app.yaml", func(fs afero.Fs) { + app, err := NewApp010(fs, "/") + require.NoError(t, err) + + envs, err := app.Environments() + require.NoError(t, err) + + envLen := len(envs) + + newEnv := &EnvironmentSpec{ + Destination: &EnvironmentDestinationSpec{ + Namespace: "some-namespace", + Server: "http://example.com", + }, + Path: "us-west/qa", + } + + k8sSpecFlag := "version:v1.8.7" + err = app.AddEnvironment("us-west/qa", k8sSpecFlag, newEnv) + require.NoError(t, err) + + envs, err = app.Environments() + require.NoError(t, err) + require.Len(t, envs, envLen+1) + + _, err = app.Environment("us-west/qa") + require.NoError(t, err) + }) +} + +func TestApp010_RemoveEnvironment(t *testing.T) { + withApp010Fs(t, "app010_app.yaml", func(fs afero.Fs) { + app, err := NewApp010(fs, "/") + require.NoError(t, err) + + _, err = app.Environment("default") + require.NoError(t, err) + + err = app.RemoveEnvironment("default") + require.NoError(t, err) + + app, err = NewApp010(fs, "/") + require.NoError(t, err) + + _, err = app.Environment("default") + require.Error(t, err) + }) +} + +func withApp010Fs(t *testing.T, appName string, fn func(fs afero.Fs)) { + ogLibUpdater := LibUpdater + LibUpdater = func(fs afero.Fs, k8sSpecFlag string, libPath string, useVersionPath bool) error { + return nil + } + + defer func() { + LibUpdater = ogLibUpdater + }() + + fs := afero.NewMemMapFs() + stageFile(t, fs, appName, "/app.yaml") + + fn(fs) +} + +func stageFile(t *testing.T, fs afero.Fs, src, dest string) { + in := filepath.Join("testdata", src) + + b, err := ioutil.ReadFile(in) + require.NoError(t, err) + + dir := filepath.Dir(dest) + err = fs.MkdirAll(dir, 0755) + require.NoError(t, err) + + err = afero.WriteFile(fs, dest, b, 0644) + require.NoError(t, err) +} diff --git a/metadata/app/mocks/App.go b/metadata/app/mocks/App.go new file mode 100644 index 00000000..898067f2 --- /dev/null +++ b/metadata/app/mocks/App.go @@ -0,0 +1,165 @@ +// Code generated by mockery v1.0.0 +package mocks + +import app "github.com/ksonnet/ksonnet/metadata/app" +import mock "github.com/stretchr/testify/mock" + +// App is an autogenerated mock type for the App type +type App struct { + mock.Mock +} + +// AddEnvironment provides a mock function with given fields: name, k8sSpecFlag, spec +func (_m *App) AddEnvironment(name string, k8sSpecFlag string, spec *app.EnvironmentSpec) error { + ret := _m.Called(name, k8sSpecFlag, spec) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, *app.EnvironmentSpec) error); ok { + r0 = rf(name, k8sSpecFlag, spec) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Environment provides a mock function with given fields: name +func (_m *App) Environment(name string) (*app.EnvironmentSpec, error) { + ret := _m.Called(name) + + var r0 *app.EnvironmentSpec + if rf, ok := ret.Get(0).(func(string) *app.EnvironmentSpec); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*app.EnvironmentSpec) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Environments provides a mock function with given fields: +func (_m *App) Environments() (app.EnvironmentSpecs, error) { + ret := _m.Called() + + var r0 app.EnvironmentSpecs + if rf, ok := ret.Get(0).(func() app.EnvironmentSpecs); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(app.EnvironmentSpecs) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Init provides a mock function with given fields: +func (_m *App) Init() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// LibPath provides a mock function with given fields: envName +func (_m *App) LibPath(envName string) (string, error) { + ret := _m.Called(envName) + + var r0 string + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(envName) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(envName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Libraries provides a mock function with given fields: +func (_m *App) Libraries() app.LibraryRefSpecs { + ret := _m.Called() + + var r0 app.LibraryRefSpecs + if rf, ok := ret.Get(0).(func() app.LibraryRefSpecs); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(app.LibraryRefSpecs) + } + } + + return r0 +} + +// Registries provides a mock function with given fields: +func (_m *App) Registries() app.RegistryRefSpecs { + ret := _m.Called() + + var r0 app.RegistryRefSpecs + if rf, ok := ret.Get(0).(func() app.RegistryRefSpecs); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(app.RegistryRefSpecs) + } + } + + return r0 +} + +// RemoveEnvironment provides a mock function with given fields: name +func (_m *App) RemoveEnvironment(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Upgrade provides a mock function with given fields: dryRun +func (_m *App) Upgrade(dryRun bool) error { + ret := _m.Called(dryRun) + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(dryRun) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/metadata/app/schema.go b/metadata/app/schema.go index 1e489909..12c4a36e 100644 --- a/metadata/app/schema.go +++ b/metadata/app/schema.go @@ -17,15 +17,17 @@ package app import ( "fmt" + "path/filepath" "github.com/blang/semver" "github.com/ghodss/yaml" "github.com/pkg/errors" + "github.com/spf13/afero" ) const ( // DefaultAPIVersion is the default ks API version to use if not specified. - DefaultAPIVersion = "0.0.1" + DefaultAPIVersion = "0.1.0" // Kind is the schema resource type. Kind = "ksonnet.io/app" // DefaultVersion is the default version of the app schema. @@ -64,6 +66,51 @@ type Spec struct { License string `json:"license,omitempty"` } +// Read will return the specification for a ksonnet application. +func Read(fs afero.Fs, appRoot string) (*Spec, error) { + bytes, err := afero.ReadFile(fs, specPath(appRoot)) + if err != nil { + return nil, err + } + + schema, err := Unmarshal(bytes) + if err != nil { + return nil, err + } + + if schema.Contributors == nil { + schema.Contributors = ContributorSpecs{} + } + + if schema.Registries == nil { + schema.Registries = RegistryRefSpecs{} + } + + if schema.Libraries == nil { + schema.Libraries = LibraryRefSpecs{} + } + + if schema.Environments == nil { + schema.Environments = EnvironmentSpecs{} + } + + return schema, nil +} + +// Write writes the provided spec to file system. +func Write(fs afero.Fs, appRoot string, spec *Spec) error { + data, err := spec.Marshal() + if err != nil { + return err + } + + return afero.WriteFile(fs, specPath(appRoot), data, DefaultFilePermissions) +} + +func specPath(appRoot string) string { + return filepath.Join(appRoot, appYamlName) +} + // RepositorySpec defines the spec for the upstream repository of this project. type RepositorySpec struct { Type string `json:"type"` @@ -185,11 +232,17 @@ func (s *Spec) AddRegistryRef(registryRefSpec *RegistryRefSpec) error { } func (s *Spec) validate() error { + if s.APIVersion == "0.0.0" { + return errors.New("invalid version") + } + compatVer, _ := semver.Make(DefaultAPIVersion) ver, err := semver.Make(s.APIVersion) if err != nil { return errors.Wrap(err, "Failed to parse version in app spec") - } else if compatVer.Compare(ver) != 0 { + } + + if compatVer.Compare(ver) < 0 { return fmt.Errorf( "Current app uses unsupported spec version '%s' (this client only supports %s)", s.APIVersion, @@ -249,7 +302,7 @@ func (s *Spec) UpdateEnvironmentSpec(name string, spec *EnvironmentSpec) error { _, environmentSpecExists := s.Environments[name] if !environmentSpecExists { - return ErrEnvironmentNotExists + return errors.Errorf("Environment with name %q does not exist", name) } if name != spec.Name { diff --git a/metadata/app/schema_test.go b/metadata/app/schema_test.go index 4b6cd394..a7d2badd 100644 --- a/metadata/app/schema_test.go +++ b/metadata/app/schema_test.go @@ -53,23 +53,13 @@ func TestApiVersionValidate(t *testing.T) { // Versions that we accept. {spec: "0.0.1", err: false}, {spec: "0.0.1+build.1", err: false}, + {spec: "0.1.0-alpha", err: false}, + {spec: "0.1.0+build.1"}, // Other versions. {spec: "0.0.0", err: true}, - {spec: "0.1.0", err: true}, + {spec: "0.1.0"}, {spec: "1.0.0", err: true}, - - // Builds and pre-releases of current version. - {spec: "0.0.1-alpha", err: true}, - {spec: "0.0.1-beta+build.2", err: true}, - - // Other versions. - {spec: "0.1.0-alpha", err: true}, - {spec: "0.1.0+build.1", err: true}, - {spec: "0.1.0-beta+build.2", err: true}, - {spec: "1.0.0-alpha", err: true}, - {spec: "1.0.0+build.1", err: true}, - {spec: "1.0.0-beta+build.2", err: true}, } for _, test := range tests { diff --git a/metadata/app/testdata/app001_app.yaml b/metadata/app/testdata/app001_app.yaml new file mode 100644 index 00000000..9ec75175 --- /dev/null +++ b/metadata/app/testdata/app001_app.yaml @@ -0,0 +1,9 @@ +apiVersion: 0.1.0 +kind: ksonnet.io/app +name: test-get-envs +registries: + incubator: + gitVersion: null + protocol: "" + uri: "" +version: 0.0.1 \ No newline at end of file diff --git a/metadata/app/testdata/app010_app.yaml b/metadata/app/testdata/app010_app.yaml new file mode 100644 index 00000000..c30e5fe5 --- /dev/null +++ b/metadata/app/testdata/app010_app.yaml @@ -0,0 +1,34 @@ +apiVersion: 0.1.0 +environments: + default: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: v1.7.0 + path: default + us-east/test: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: "" + path: us-east/test + us-west/prod: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: "" + path: us-west/prod + us-west/test: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: "" + path: us-west/test +kind: ksonnet.io/app +name: test-get-envs +registries: + incubator: + gitVersion: null + protocol: "" + uri: "" +version: 0.0.1 \ No newline at end of file diff --git a/metadata/app/testdata/spec.json b/metadata/app/testdata/spec.json new file mode 100644 index 00000000..945acc46 --- /dev/null +++ b/metadata/app/testdata/spec.json @@ -0,0 +1,4 @@ +{ + "namespace": "some-namespace", + "server": "http://example.com" +} \ No newline at end of file diff --git a/metadata/app/testdata/swagger.json b/metadata/app/testdata/swagger.json new file mode 100644 index 00000000..ff4ba3b9 --- /dev/null +++ b/metadata/app/testdata/swagger.json @@ -0,0 +1,5 @@ +{ + "info": { + "version": "v1.7.0" + } +} \ No newline at end of file diff --git a/metadata/app/testdata/upgrade001.txt b/metadata/app/testdata/upgrade001.txt new file mode 100644 index 00000000..2b103976 --- /dev/null +++ b/metadata/app/testdata/upgrade001.txt @@ -0,0 +1,45 @@ + +[dry run] Upgrading application settings from version 0.0.1 to to 0.1.0. +[dry run] Converting 0.0.1 environments to 0.1.0a: +[dry run] * adding the environment description in environment `default to `app.yaml`. +[dry run] * adding the environment description in environment `us-east/test to `app.yaml`. +[dry run] * adding the environment description in environment `us-west/prod to `app.yaml`. +[dry run] * adding the environment description in environment `us-west/test to `app.yaml`. + +[dry run] Upgraded app.yaml: +apiVersion: 0.1.0 +environments: + default: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: v1.7.0 + path: default + us-east/test: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: v1.7.0 + path: us-east/test + us-west/prod: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: v1.7.0 + path: us-west/prod + us-west/test: + destination: + namespace: some-namespace + server: http://example.com + k8sVersion: v1.7.0 + path: us-west/test +kind: ksonnet.io/app +name: test-get-envs +registries: + incubator: + gitVersion: null + protocol: "" + uri: "" +version: 0.0.1 + +[dry run] You can preform the migration by running `ks upgrade`. diff --git a/metadata/component.go b/metadata/component.go index 7cf8eaca..79648a36 100644 --- a/metadata/component.go +++ b/metadata/component.go @@ -157,7 +157,6 @@ func (m *manager) DeleteComponent(name string) error { } 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 diff --git a/metadata/component_test.go b/metadata/component_test.go index 5693508d..8e0ef53e 100644 --- a/metadata/component_test.go +++ b/metadata/component_test.go @@ -22,6 +22,7 @@ import ( "testing" str "github.com/ksonnet/ksonnet/strings" + "github.com/spf13/afero" ) const ( @@ -31,12 +32,12 @@ const ( componentFile2 = "component2.jsonnet" ) -func populateComponentPaths(t *testing.T) *manager { +func populateComponentPaths(t *testing.T, fs afero.Fs) *manager { specFlag := fmt.Sprintf("file:%s", blankSwagger) appPath := componentsPath reg := newMockRegistryManager("incubator") - m, err := initManager("componentPaths", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, testFS) + m, err := initManager("componentPaths", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, fs) if err != nil { t.Fatalf("Failed to init cluster spec: %v", err) } @@ -44,7 +45,7 @@ func populateComponentPaths(t *testing.T) *manager { // Create empty app file. components := str.AppendToPath(appPath, componentsDir) appFile1 := str.AppendToPath(components, componentFile1) - f1, err := testFS.OpenFile(appFile1, os.O_RDONLY|os.O_CREATE, 0777) + f1, err := fs.OpenFile(appFile1, os.O_RDONLY|os.O_CREATE, 0777) if err != nil { t.Fatalf("Failed to touch app file '%s'\n%v", appFile1, err) } @@ -52,12 +53,12 @@ func populateComponentPaths(t *testing.T) *manager { // Create empty file in a nested directory. appSubdir := str.AppendToPath(components, componentSubdir) - err = testFS.MkdirAll(appSubdir, os.ModePerm) + err = fs.MkdirAll(appSubdir, os.ModePerm) if err != nil { t.Fatalf("Failed to create directory '%s'\n%v", appSubdir, err) } appFile2 := str.AppendToPath(appSubdir, componentFile2) - f2, err := testFS.OpenFile(appFile2, os.O_RDONLY|os.O_CREATE, 0777) + f2, err := fs.OpenFile(appFile2, os.O_RDONLY|os.O_CREATE, 0777) if err != nil { t.Fatalf("Failed to touch app file '%s'\n%v", appFile1, err) } @@ -65,7 +66,7 @@ func populateComponentPaths(t *testing.T) *manager { // Create a directory that won't be listed in the call to `ComponentPaths`. unlistedDir := str.AppendToPath(components, "doNotListMe") - err = testFS.MkdirAll(unlistedDir, os.ModePerm) + err = fs.MkdirAll(unlistedDir, os.ModePerm) if err != nil { t.Fatalf("Failed to create directory '%s'\n%v", unlistedDir, err) } @@ -73,47 +74,44 @@ func populateComponentPaths(t *testing.T) *manager { return m } -func cleanComponentPaths(t *testing.T) { - testFS.RemoveAll(componentsPath) -} - func TestComponentPaths(t *testing.T) { - m := populateComponentPaths(t) - defer cleanComponentPaths(t) + withFs(func(fs afero.Fs) { + m := populateComponentPaths(t, fs) - paths, err := m.ComponentPaths() - if err != nil { - t.Fatalf("Failed to find component paths: %v", err) - } + paths, err := m.ComponentPaths() + if err != nil { + t.Fatalf("Failed to find component paths: %v", err) + } - sort.Slice(paths, func(i, j int) bool { return paths[i] < paths[j] }) + sort.Slice(paths, func(i, j int) bool { return paths[i] < paths[j] }) - expectedPath1 := fmt.Sprintf("%s/components/%s", componentsPath, componentFile1) - expectedPath2 := fmt.Sprintf("%s/components/%s/%s", componentsPath, componentSubdir, componentFile2) + expectedPath1 := fmt.Sprintf("%s/components/%s", componentsPath, componentFile1) + expectedPath2 := fmt.Sprintf("%s/components/%s/%s", componentsPath, componentSubdir, componentFile2) - if len(paths) != 2 || paths[0] != expectedPath1 || paths[1] != expectedPath2 { - t.Fatalf("m.ComponentPaths failed; expected '%s', got '%s'", []string{expectedPath1, expectedPath2}, paths) - } + if len(paths) != 2 || paths[0] != expectedPath1 || paths[1] != expectedPath2 { + t.Fatalf("m.ComponentPaths failed; expected '%s', got '%s'", []string{expectedPath1, expectedPath2}, paths) + } + }) } // TODO: this logic and tests should be moved to the components namespace. func TestGetAllComponents(t *testing.T) { - m := populateComponentPaths(t) - defer cleanComponentPaths(t) + withFs(func(fs afero.Fs) { + m := populateComponentPaths(t, fs) - components, err := m.GetAllComponents() - if err != nil { - t.Fatalf("Failed to get all components, %v", err) - } + components, err := m.GetAllComponents() + if err != nil { + t.Fatalf("Failed to get all components, %v", err) + } - expected1 := strings.TrimSuffix(componentFile1, ".jsonnet") + expected1 := strings.TrimSuffix(componentFile1, ".jsonnet") - 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 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) + } + }) } diff --git a/metadata/environment.go b/metadata/environment.go index d00fe04c..92be02b7 100644 --- a/metadata/environment.go +++ b/metadata/environment.go @@ -18,17 +18,12 @@ package metadata import ( "bytes" "fmt" - "os" - "path" - "path/filepath" - "github.com/ksonnet/ksonnet/metadata/app" + "github.com/ksonnet/ksonnet/env" "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" param "github.com/ksonnet/ksonnet/metadata/params" ) @@ -41,333 +36,98 @@ const ( paramsFileName = "params.libsonnet" ) -var envPaths = []string{ - // environment base override file - envFileName, - // params file - paramsFileName, -} +var ( + // envCreate is a function which creates environments. + envCreate = env.Create +) func (m *manager) CreateEnvironment(name, server, namespace, k8sSpecFlag string) error { - // generate the lib data for this kubernetes version - libManager, err := lib.NewManager(k8sSpecFlag, m.appFS, m.libPath) - if err != nil { - return err - } - - if err := libManager.GenerateLibData(); err != nil { - return err - } - - // add the environment to the app spec - appSpec, err := m.AppSpec() - if err != nil { - return err - } - - exists, err := m.environmentExists(name) - if err != nil { - return err - } - if exists { - return fmt.Errorf("Environment '%s' already exists", name) - } - - // ensure environment name does not contain punctuation - if !isValidName(name) { - return fmt.Errorf("Environment name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name) - } - - if namespace == "" { - namespace = "default" - } - - log.Infof("Creating environment '%s' with namespace '%s', pointing at server at address '%s'", name, namespace, server) - - envPath := str.AppendToPath(m.environmentsPath, name) - err = m.appFS.MkdirAll(envPath, defaultFolderPermissions) + a, err := m.App() if err != nil { return err } - metadata := []struct { - path string - data []byte - }{ - { - // environment base override file - str.AppendToPath(envPath, envFileName), - m.generateOverrideData(), - }, - { - // params file - str.AppendToPath(envPath, paramsFileName), - m.generateParamsData(), - }, - } + config := env.CreateConfig{ + App: a, + Destination: env.NewDestination(server, namespace), + Fs: m.appFS, + K8sSpecFlag: k8sSpecFlag, + Name: name, + RootPath: m.rootPath, - for _, a := range metadata { - fileName := path.Base(a.path) - log.Debugf("Generating '%s', length: %d", fileName, len(a.data)) - if err = afero.WriteFile(m.appFS, a.path, a.data, defaultFilePermissions); err != nil { - log.Debugf("Failed to write '%s'", fileName) - return err - } + OverrideData: m.generateOverrideData(), + ParamsData: m.generateParamsData(), } - // update app.yaml - err = appSpec.AddEnvironmentSpec(&app.EnvironmentSpec{ - Name: name, - Path: name, - Destination: &app.EnvironmentDestinationSpec{ - Server: server, - Namespace: namespace, - }, - KubernetesVersion: libManager.K8sVersion, - }) - - if err != nil { - return err - } + return envCreate(config) - return m.WriteAppSpec(appSpec) } func (m *manager) DeleteEnvironment(name string) error { - app, err := m.AppSpec() - if err != nil { - return err - } - - env, err := m.GetEnvironment(name) + a, err := m.App() if err != nil { return err } - envPath := str.AppendToPath(m.environmentsPath, env.Path) - - log.Infof("Deleting environment '%s' with metadata at path '%s'", name, envPath) - - // Remove the directory and all files within the environment path. - err = m.appFS.RemoveAll(envPath) - if err != nil { - log.Debugf("Failed to remove environment directory at path '%s'", envPath) - return err + config := env.DeleteConfig{ + App: a, + AppRoot: m.rootPath, + Name: name, + Fs: m.appFS, } - - // Need to ensure empty parent directories are also removed. - log.Debug("Removing empty parent directories, if any") - err = m.cleanEmptyParentDirs(name) - if err != nil { - return err - } - - // Update app spec. - if err := m.WriteAppSpec(app); err != nil { - return err - } - - log.Infof("Successfully removed environment '%s'", name) - return nil + return env.Delete(config) } -func (m *manager) GetEnvironments() (app.EnvironmentSpecs, error) { - if err := m.errorOnSpecFile(); err != nil { - return nil, err - } - - app, err := m.AppSpec() +func (m *manager) GetEnvironments() (map[string]env.Env, error) { + a, err := m.App() if err != nil { return nil, err } log.Debug("Retrieving all environments") - return app.GetEnvironmentSpecs(), nil + return env.List(a) } -func (m *manager) GetEnvironment(name string) (*app.EnvironmentSpec, error) { - if err := m.errorOnSpecFile(); err != nil { - return nil, err - } - - app, err := m.AppSpec() +func (m *manager) GetEnvironment(name string) (*env.Env, error) { + a, err := m.App() if err != nil { return nil, err } - env, ok := app.GetEnvironmentSpec(name) - if !ok { - return nil, fmt.Errorf("Environment '%s' does not exist", name) - } - - return env, nil + return env.Retrieve(a, name) } -func (m *manager) SetEnvironment(name, desiredName string) error { - if name == desiredName || len(desiredName) == 0 { - return nil - } - - // ensure new environment name does not contain punctuation - if !isValidName(desiredName) { - return fmt.Errorf("Environment name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name) - } - - // Ensure not overwriting another environment - desiredExists, err := m.environmentExists(desiredName) +func (m *manager) SetEnvironment(from, to string) error { + a, err := m.App() if err != nil { - log.Debugf("Failed to check whether environment '%s' already exists", desiredName) return err } - if desiredExists { - return fmt.Errorf("Failed to update '%s'; environment '%s' exists", name, desiredName) - } - - log.Infof("Setting environment name from '%s' to '%s'", name, desiredName) - - // - // Update app spec. We will write out the app spec changes once all file - // move operations are complete. - // - appSpec, err := m.AppSpec() - if err != nil { - return err + config := env.RenameConfig{ + App: a, + AppRoot: m.rootPath, + Fs: m.appFS, } - current, err := m.GetEnvironment(name) - if err != nil { - return err - } - - err = appSpec.UpdateEnvironmentSpec(name, &app.EnvironmentSpec{ - Name: desiredName, - Destination: current.Destination, - KubernetesVersion: current.KubernetesVersion, - Targets: current.Targets, - Path: desiredName, - }) - - if err != nil { - return err - } - - // - // If the name has changed, the directory location needs to be moved to - // reflect the change. - // - - pathOld := str.AppendToPath(m.environmentsPath, name) - pathNew := str.AppendToPath(m.environmentsPath, desiredName) - exists, err := afero.DirExists(m.appFS, pathNew) - if err != nil { - return err - } - - if exists { - // we know that the desired path is not an environment from - // the check earlier. This is an intermediate directory. - // We need to move the file contents. - m.tryMvEnvDir(pathOld, pathNew) - } else if filepath.HasPrefix(pathNew, pathOld) { - // the new directory is a child of the old directory -- - // rename won't work. - err = m.appFS.MkdirAll(pathNew, defaultFolderPermissions) - if err != nil { - return err - } - m.tryMvEnvDir(pathOld, pathNew) - } else { - // Need to first create subdirectories that don't exist - intermediatePath := path.Dir(pathNew) - log.Debugf("Moving directory at path '%s' to '%s'", pathOld, pathNew) - err = m.appFS.MkdirAll(intermediatePath, defaultFolderPermissions) - if err != nil { - return err - } - // finally, move the directory - err = m.appFS.Rename(pathOld, pathNew) - if err != nil { - log.Debugf("Failed to move path '%s' to '%s", pathOld, pathNew) - return err - } - } - - // clean up any empty parent directory paths - err = m.cleanEmptyParentDirs(name) - if err != nil { - return err - } - - m.WriteAppSpec(appSpec) - - log.Infof("Successfully updated environment '%s'", name) - return nil + return env.Rename(from, to, config) } -func (m *manager) GetEnvironmentParams(name string) (map[string]param.Params, error) { - exists, err := m.environmentExists(name) - if err != nil { - return nil, err - } - if !exists { - return nil, fmt.Errorf("Environment '%s' does not exist", name) +func (m *manager) GetEnvironmentParams(name, nsName string) (map[string]param.Params, error) { + config := env.GetParamsConfig{ + AppRoot: m.rootPath, + Fs: m.appFS, } - // Get the environment specific params - envParamsPath := str.AppendToPath(m.environmentsPath, name, paramsFileName) - envParamsText, err := afero.ReadFile(m.appFS, envParamsPath) - if err != nil { - return nil, err - } - envParams, err := param.GetAllEnvironmentParams(string(envParamsText)) - if err != nil { - 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(cwd) - if err != nil { - return nil, err - } - - // Merge the param sets, replacing the component params if the environment params override - return mergeParamMaps(componentParams, envParams), nil + return env.GetParams(name, nsName, config) } -func (m *manager) SetEnvironmentParams(env, component string, params param.Params) error { - exists, err := m.environmentExists(env) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("Environment '%s' does not exist", env) - } - - path := str.AppendToPath(m.environmentsPath, env, paramsFileName) - - text, err := afero.ReadFile(m.appFS, path) - if err != nil { - return err - } - - appended, err := param.SetEnvironmentParams(component, string(text), params) - if err != nil { - return err - } - - err = afero.WriteFile(m.appFS, path, []byte(appended), defaultFilePermissions) - if err != nil { - return err +func (m *manager) SetEnvironmentParams(envName, component string, params param.Params) error { + config := env.SetParamsConfig{ + AppRoot: m.rootPath, + Fs: m.appFS, } - log.Debugf("Successfully set parameters for component '%s' at environment '%s'", component, env) - return nil + return env.SetParams(envName, component, params, config) } func (m *manager) EnvPaths(env string) (libPath, mainPath, paramsPath string, err error) { @@ -387,98 +147,13 @@ func (m *manager) makeEnvPaths(env string) (mainPath, paramsPath string) { return } -func (m *manager) getLibPath(env string) (string, error) { - envSpec, err := m.GetEnvironment(env) +func (m *manager) getLibPath(envName string) (string, error) { + a, err := m.App() if err != nil { return "", err } - libManager, err := lib.NewManager(fmt.Sprintf("version:%s", envSpec.KubernetesVersion), m.appFS, m.libPath) - if err != nil { - return "", err - } - - return libManager.GetLibPath() -} - -func (m *manager) errorOnSpecFile() error { - return afero.Walk(m.appFS, m.environmentsPath, func(p string, f os.FileInfo, err error) error { - if err != nil { - log.Debugf("Failed to walk path %s", p) - return err - } - isDir, err := afero.IsDir(m.appFS, p) - if err != nil { - log.Debugf("Failed to check whether the path at %s is a directory", p) - return err - } - if isDir { - specPath := filepath.Join(p, "spec.json") - specFileExists, err := afero.Exists(m.appFS, specPath) - if err != nil { - log.Debugf("Failed to check whether spec.json exists") - return err - } - if specFileExists { - // TODO, we should point users to a tutorial. - return fmt.Errorf("Environment's directory contains a dated model containing the 'spec.json' file. Please migrate to the new model by adding environments data to app.yaml") - } - } - - return nil - }) -} - -func (m *manager) tryMvEnvDir(dirPathOld, dirPathNew string) error { - // first ensure none of these paths exists in the new directory - for _, p := range envPaths { - path := str.AppendToPath(dirPathNew, p) - if exists, err := afero.Exists(m.appFS, path); err != nil { - return err - } else if exists { - return fmt.Errorf("%s already exists", path) - } - } - - // note: afero and go does not provide simple ways to move the - // contents. We'll have to rename them individually. - for _, p := range envPaths { - err := m.appFS.Rename(str.AppendToPath(dirPathOld, p), str.AppendToPath(dirPathNew, p)) - if err != nil { - return err - } - } - // clean up the old directory if it is empty - if empty, err := afero.IsEmpty(m.appFS, dirPathOld); err != nil { - return err - } else if empty { - return m.appFS.RemoveAll(dirPathOld) - } - return nil -} - -func (m *manager) cleanEmptyParentDirs(name string) error { - // clean up any empty parent directory paths - log.Debug("Removing empty parent directories, if any") - parentDir := name - for parentDir != "." { - parentDir = filepath.Dir(parentDir) - parentPath := str.AppendToPath(m.environmentsPath, parentDir) - - isEmpty, err := afero.IsEmpty(m.appFS, parentPath) - if err != nil { - log.Debugf("Failed to check whether parent directory at path '%s' is empty", parentPath) - return err - } - if isEmpty { - log.Debugf("Failed to remove parent directory at path '%s'", parentPath) - err := m.appFS.RemoveAll(parentPath) - if err != nil { - return err - } - } - } - return nil + return a.LibPath(envName) } func (m *manager) generateOverrideData() []byte { @@ -509,30 +184,3 @@ params + { } `) } - -func (m *manager) environmentExists(name string) (bool, error) { - appSpec, err := m.AppSpec() - if err != nil { - return false, err - } - - if err := m.errorOnSpecFile(); err != nil { - return false, err - } - - _, ok := appSpec.GetEnvironmentSpec(name) - return ok, nil -} - -func mergeParamMaps(base, overrides map[string]param.Params) map[string]param.Params { - for component, params := range overrides { - if _, contains := base[component]; !contains { - base[component] = params - } else { - for k, v := range params { - base[component][k] = v - } - } - } - return base -} diff --git a/metadata/environment_test.go b/metadata/environment_test.go index ef74fc11..91f293c3 100644 --- a/metadata/environment_test.go +++ b/metadata/environment_test.go @@ -17,14 +17,15 @@ package metadata import ( "fmt" - "reflect" + "path/filepath" "strings" "testing" "github.com/ksonnet/ksonnet/metadata/app" + "github.com/ksonnet/ksonnet/metadata/app/mocks" str "github.com/ksonnet/ksonnet/strings" + "github.com/stretchr/testify/require" - param "github.com/ksonnet/ksonnet/metadata/params" "github.com/spf13/afero" ) @@ -40,216 +41,228 @@ var ( mockEnvs = []string{defaultEnvName, mockEnvName, mockEnvName2, mockEnvName3} ) -func mockEnvironments(t *testing.T, appName string) *manager { - return mockEnvironmentsWith(t, appName, mockEnvs) +func mockEnvironments(t *testing.T, fs afero.Fs, appName string) *manager { + return mockEnvironmentsWith(t, fs, appName, mockEnvs) } -func mockEnvironmentsWith(t *testing.T, appName string, envNames []string) *manager { +func mockEnvironmentsWith(t *testing.T, fs afero.Fs, appName string, envNames []string) *manager { specFlag := fmt.Sprintf("file:%s", blankSwagger) reg := newMockRegistryManager("incubator") - m, err := initManager(appName, appName, &specFlag, &mockAPIServer, &mockNamespace, reg, testFS) + root := filepath.Join("/", appName) + m, err := initManager(appName, root, &specFlag, &mockAPIServer, &mockNamespace, reg, fs) if err != nil { t.Fatalf("Failed to init cluster spec: %v", err) } for _, env := range envNames { envPath := str.AppendToPath(m.environmentsPath, env) - testFS.Mkdir(envPath, defaultFolderPermissions) - testDirExists(t, envPath) + fs.Mkdir(envPath, defaultFolderPermissions) + testDirExists(t, fs, envPath) envFilePath := str.AppendToPath(envPath, envFileName) envFileData := m.generateOverrideData() - err = afero.WriteFile(testFS, envFilePath, envFileData, defaultFilePermissions) + err = afero.WriteFile(fs, envFilePath, envFileData, defaultFilePermissions) if err != nil { t.Fatalf("Could not write file at path: %s", envFilePath) } - testFileExists(t, envFilePath) + testFileExists(t, fs, envFilePath) paramsPath := str.AppendToPath(envPath, paramsFileName) paramsData := m.generateParamsData() - err = afero.WriteFile(testFS, paramsPath, paramsData, defaultFilePermissions) + err = afero.WriteFile(fs, paramsPath, paramsData, defaultFilePermissions) if err != nil { t.Fatalf("Could not write file at path: %s", paramsPath) } - testFileExists(t, paramsPath) + testFileExists(t, fs, paramsPath) - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { t.Fatal("Could not retrieve app spec") } appSpec.AddEnvironmentSpec(&app.EnvironmentSpec{ - Name: env, - Path: env, + Name: env, + Path: env, + KubernetesVersion: "v1.8.7", Destination: &app.EnvironmentDestinationSpec{ Server: mockAPIServer, Namespace: mockNamespace, }, }) - m.WriteAppSpec(appSpec) + err = app.Write(m.appFS, m.rootPath, appSpec) + require.NoError(t, err) } return m } -func testDirExists(t *testing.T, path string) { - exists, err := afero.DirExists(testFS, path) - if err != nil { - t.Fatalf("Expected directory at '%s' to exist, but failed:\n%v", path, err) - } else if !exists { - t.Fatalf("Expected directory at '%s' to exist, but it does not", path) - } +func testDirExists(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.DirExists(fs, path) + require.NoError(t, err, "Checking %q failed", path) + require.True(t, exists, "Expected directory %q to exist", path) } -func testDirNotExists(t *testing.T, path string) { - exists, err := afero.DirExists(testFS, path) - if err != nil { - t.Fatalf("Expected directory at '%s' to be removed, but failed:\n%v", path, err) - } else if exists { - t.Fatalf("Expected directory at '%s' to be removed, but it exists", path) - } +func testDirNotExists(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.DirExists(fs, path) + require.NoError(t, err, "Checking %q failed", path) + require.False(t, exists, "Expected directory %q to not exist", path) } -func testFileExists(t *testing.T, path string) { - exists, err := afero.Exists(testFS, path) - if err != nil { - t.Fatalf("Expected file at '%s' to exist, but failed:\n%v", path, err) - } else if !exists { - t.Fatalf("Expected file at '%s' to exist, but it does not", path) - } +func testFileExists(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.Exists(fs, path) + require.NoError(t, err, "Checking %q failed", path) + require.True(t, exists, "Expected file %q to exist", path) } func TestDeleteEnvironment(t *testing.T) { - appName := "test-delete-envs" - m := mockEnvironments(t, appName) - - // Test that both directory and empty parent directory is deleted. - expectedPath := str.AppendToPath(m.environmentsPath, mockEnvName3) - parentDir := strings.Split(mockEnvName3, "/")[0] - expectedParentPath := str.AppendToPath(m.environmentsPath, parentDir) - err := m.DeleteEnvironment(mockEnvName3) - if err != nil { - t.Fatalf("Expected %s to be deleted but got err:\n %s", mockEnvName3, err) - } - testDirNotExists(t, expectedPath) - testDirNotExists(t, expectedParentPath) - - // Test that only leaf directory is deleted if parent directory is shared - expectedPath = str.AppendToPath(m.environmentsPath, mockEnvName2) - parentDir = strings.Split(mockEnvName2, "/")[0] - expectedParentPath = str.AppendToPath(m.environmentsPath, parentDir) - err = m.DeleteEnvironment(mockEnvName2) - if err != nil { - t.Fatalf("Expected %s to be deleted but got err:\n %s", mockEnvName3, err) - } - testDirNotExists(t, expectedPath) - testDirExists(t, expectedParentPath) + withFs(func(fs afero.Fs) { + appName := "test-delete-envs" + appMock := &mocks.App{} + appMock.On("RemoveEnvironment", "us-east/test").Return(nil) + appMock.On("RemoveEnvironment", "us-west/prod").Return(nil) + + m := mockEnvironments(t, fs, appName) + + // Test that both directory and empty parent directory is deleted. + expectedPath, err := filepath.Abs(filepath.Join("/", m.rootPath, "environments", mockEnvName3)) + require.NoError(t, err) + parentDir := strings.Split(mockEnvName3, "/")[0] + expectedParentPath, err := filepath.Abs(filepath.Join("/", m.rootPath, "environments", parentDir)) + require.NoError(t, err) + err = m.DeleteEnvironment(mockEnvName3) + if err != nil { + t.Fatalf("Expected %s to be deleted but got err:\n %s", mockEnvName3, err) + } + testDirNotExists(t, fs, expectedPath) + testDirNotExists(t, fs, expectedParentPath) + + // Test that only leaf directory is deleted if parent directory is shared + expectedPath = str.AppendToPath("/", m.environmentsPath, mockEnvName2) + parentDir = strings.Split(mockEnvName2, "/")[0] + expectedParentPath = str.AppendToPath("/", m.environmentsPath, parentDir) + err = m.DeleteEnvironment(mockEnvName2) + if err != nil { + t.Fatalf("Expected %s to be deleted but got err:\n %s", mockEnvName3, err) + } + + testDirNotExists(t, fs, expectedPath) + testDirExists(t, fs, expectedParentPath) + }) } func TestGetEnvironments(t *testing.T) { - m := mockEnvironments(t, "test-get-envs") + withFs(func(fs afero.Fs) { + appMock := &mocks.App{} + appMock.On("Environments").Return(nil, nil) - envs, err := m.GetEnvironments() - if err != nil { - t.Fatalf("Expected to successfully get environments but failed:\n %s", err) - } + m := mockEnvironments(t, fs, "test-get-envs") - if len(envs) != 4 { - t.Fatalf("Expected to get %d environments, got %d", 4, len(envs)) - } + envs, err := m.GetEnvironments() + if err != nil { + t.Fatalf("Expected to successfully get environments but failed:\n %s", err) + } - name := envs[mockEnvName].Name - if name != mockEnvName { - t.Fatalf("Expected env name to be '%s', got '%s'", mockEnvName, name) - } + if len(envs) != 4 { + t.Fatalf("Expected to get %d environments, got %d", 4, len(envs)) + } - server := envs[mockEnvName].Destination.Server - if server != mockAPIServer { - t.Fatalf("Expected env server to be %s, got %s", mockAPIServer, server) - } + cur := envs[mockEnvName] + name := cur.Name + if name != mockEnvName { + t.Fatalf("Expected env name to be %q, got %q", mockEnvName, name) + } + + server := cur.Destination.Server() + if server != mockAPIServer { + t.Fatalf("Expected env server to be %q, got %q", mockAPIServer, server) + } + }) } func TestSetEnvironment(t *testing.T) { - appName := "test-set-envs" - m := mockEnvironments(t, appName) + withFs(func(fs afero.Fs) { + appName := "test-set-envs" + m := mockEnvironments(t, fs, appName) - setName := "new-env" + setName := "new-env" - // Test updating an environment that doesn't exist - err := m.SetEnvironment("notexists", setName) - if err == nil { - t.Fatal("Expected error when setting an environment that does not exist") - } + // Test updating an environment that doesn't exist + err := m.SetEnvironment("notexists", setName) + if err == nil { + t.Fatal("Expected error when setting an environment that does not exist") + } - // Test updating an environment to an environment that already exists - err = m.SetEnvironment(mockEnvName, mockEnvName2) - if err == nil { - t.Fatalf("Expected error when setting \"%s\" to \"%s\", because env already exists", mockEnvName, mockEnvName2) - } + // Test updating an environment to an environment that already exists + err = m.SetEnvironment(mockEnvName, mockEnvName2) + if err == nil { + t.Fatalf("Expected error when setting \"%s\" to \"%s\", because env already exists", mockEnvName, mockEnvName2) + } - // Test changing the name an existing environment. - err = m.SetEnvironment(mockEnvName, setName) - if err != nil { - t.Fatalf("Could not set \"%s\", got:\n %s", mockEnvName, err) - } + // Test changing the name an existing environment. + err = m.SetEnvironment(mockEnvName, setName) + if err != nil { + t.Fatalf("Could not set \"%s\", got:\n %s", mockEnvName, err) + } - // Ensure new env directory is created, and old directory no longer exists. - envPath := str.AppendToPath(appName, environmentsDir) - expectedPathExists := str.AppendToPath(envPath, setName) - expectedPathNotExists := str.AppendToPath(envPath, mockEnvName) - testDirExists(t, expectedPathExists) - testDirNotExists(t, expectedPathNotExists) - - // BUG: https://github.com/spf13/afero/issues/141 - // we aren't able to test this until the above is fixed. - // - // ensure all files are moved - // - // expectedFiles := []string{ - // envFileName, - // specFilename, - // paramsFileName, - // } - // for _, f := range expectedFiles { - // expectedFilePath := appendToAbsPath(expectedPathExists, f) - // testFileExists(t, string(expectedFilePath)) - // } - - tests := []struct { - appName string - nameOld string - nameNew string - }{ - // Test changing the name of an env 'us-west' to 'us-west/dev' - { - "test-set-to-child", - "us-west", - "us-west/dev", - }, - // Test changing the name of an env 'us-west/dev' to 'us-west' - { - "test-set-to-parent", - "us-west/dev", - "us-west", - }, - } + // Ensure new env directory is created, and old directory no longer exists. + envPath := str.AppendToPath(appName, environmentsDir) + expectedPathExists := filepath.Join("/", envPath, setName) + expectedPathNotExists := filepath.Join("/", envPath, mockEnvName) + testDirExists(t, fs, expectedPathExists) + testDirNotExists(t, fs, expectedPathNotExists) + + // BUG: https://github.com/spf13/afero/issues/141 + // we aren't able to test this until the above is fixed. + // + // ensure all files are moved + // + // expectedFiles := []string{ + // envFileName, + // specFilename, + // paramsFileName, + // } + // for _, f := range expectedFiles { + // expectedFilePath := appendToAbsPath(expectedPathExists, f) + // testFileExists(t, string(expectedFilePath)) + // } + + tests := []struct { + appName string + nameOld string + nameNew string + }{ + // Test changing the name of an env 'us-west' to 'us-west/dev' + { + "test-set-to-child", + "us-west", + "us-west/dev", + }, + // Test changing the name of an env 'us-west/dev' to 'us-west' + { + "test-set-to-parent", + "us-west/dev", + "us-west", + }, + } - for _, v := range tests { - m = mockEnvironmentsWith(t, v.appName, []string{v.nameOld}) - err = m.SetEnvironment(v.nameOld, v.nameNew) - if err != nil { - t.Fatalf("Could not set '%s', got:\n %s", v.nameOld, err) + for _, v := range tests { + m = mockEnvironmentsWith(t, fs, v.appName, []string{v.nameOld}) + err = m.SetEnvironment(v.nameOld, v.nameNew) + if err != nil { + t.Fatalf("Could not set '%s', got:\n %s", v.nameOld, err) + } + // Ensure new env directory is created + expectedPath := filepath.Join("/", v.appName, environmentsDir, v.nameNew) + testDirExists(t, fs, expectedPath) } - // Ensure new env directory is created - expectedPath := str.AppendToPath(v.appName, environmentsDir, v.nameNew) - testDirExists(t, expectedPath) - } + }) } func TestGenerateOverrideData(t *testing.T) { - m := mockEnvironments(t, "test-gen-override-data") + withFs(func(fs afero.Fs) { + m := mockEnvironments(t, fs, "test-gen-override-data") - expected := `local base = import "base.libsonnet"; + expected := `local base = import "base.libsonnet"; local k = import "k.libsonnet"; base + { @@ -257,17 +270,19 @@ base + { // "nginx-deployment"+: k.deployment.mixin.metadata.labels({foo: "bar"}) } ` - result := m.generateOverrideData() + result := m.generateOverrideData() - if string(result) != expected { - t.Fatalf("Expected to generate override file with data:\n%s\n,got:\n%s", expected, result) - } + if string(result) != expected { + t.Fatalf("Expected to generate override file with data:\n%s\n,got:\n%s", expected, result) + } + }) } func TestGenerateParamsData(t *testing.T) { - m := mockEnvironments(t, "test-gen-params-data") + withFs(func(fs afero.Fs) { + m := mockEnvironments(t, fs, "test-gen-params-data") - expected := `local params = import "../../components/params.libsonnet"; + expected := `local params = import "../../components/params.libsonnet"; params + { components +: { // Insert component parameter overrides here. Ex: @@ -278,64 +293,10 @@ params + { }, } ` - result := string(m.generateParamsData()) - - if result != expected { - t.Fatalf("Expected to generate params file with data:\n%s\n, got:\n%s", expected, result) - } -} - -func TestMergeParamMaps(t *testing.T) { - tests := []struct { - base map[string]param.Params - overrides map[string]param.Params - expected map[string]param.Params - }{ - { - map[string]param.Params{ - "bar": param.Params{"replicas": "5"}, - }, - map[string]param.Params{ - "foo": param.Params{"name": `"foo"`, "replicas": "1"}, - }, - map[string]param.Params{ - "bar": param.Params{"replicas": "5"}, - "foo": param.Params{"name": `"foo"`, "replicas": "1"}, - }, - }, - { - map[string]param.Params{ - "bar": param.Params{"replicas": "5"}, - }, - map[string]param.Params{ - "bar": param.Params{"name": `"foo"`}, - }, - map[string]param.Params{ - "bar": param.Params{"name": `"foo"`, "replicas": "5"}, - }, - }, - { - map[string]param.Params{ - "bar": param.Params{"name": `"bar"`, "replicas": "5"}, - "foo": param.Params{"name": `"foo"`, "replicas": "4"}, - "baz": param.Params{"name": `"baz"`, "replicas": "3"}, - }, - map[string]param.Params{ - "foo": param.Params{"replicas": "1"}, - "baz": param.Params{"name": `"foobaz"`}, - }, - map[string]param.Params{ - "bar": param.Params{"name": `"bar"`, "replicas": "5"}, - "foo": param.Params{"name": `"foo"`, "replicas": "1"}, - "baz": param.Params{"name": `"foobaz"`, "replicas": "3"}, - }, - }, - } + result := string(m.generateParamsData()) - for _, s := range tests { - result := mergeParamMaps(s.base, s.overrides) - if !reflect.DeepEqual(s.expected, result) { - t.Errorf("Wrong merge\n expected:\n%v\n got:\n%v", s.expected, result) + if result != expected { + t.Fatalf("Expected to generate params file with data:\n%s\n, got:\n%s", expected, result) } - } + }) } diff --git a/metadata/interface.go b/metadata/interface.go index 1b4eb4c1..e962c7b7 100644 --- a/metadata/interface.go +++ b/metadata/interface.go @@ -17,9 +17,8 @@ package metadata import ( "os" - "regexp" - "strings" + "github.com/ksonnet/ksonnet/env" "github.com/ksonnet/ksonnet/metadata/app" param "github.com/ksonnet/ksonnet/metadata/params" "github.com/ksonnet/ksonnet/metadata/parts" @@ -36,6 +35,8 @@ var defaultFilePermissions = os.FileMode(0644) // things like: create and delete environments; search for prototypes; vendor // libraries; and other non-core-application tasks. type Manager interface { + // App returns the object for the application. + App() (app.App, error) Root() string LibPaths() (envPath, vendorPath string) EnvPaths(env string) (libPath, mainPath, paramsPath string, err error) @@ -54,19 +55,16 @@ type Manager interface { // mapping of parameters of the form: // componentName => {param key => param val} // i.e.: "nginx" => {"replicas" => 1, "name": "nginx"} - GetEnvironmentParams(name string) (map[string]param.Params, error) + GetEnvironmentParams(name, nsName string) (map[string]param.Params, error) SetEnvironmentParams(env, component string, params param.Params) error // Environment API. CreateEnvironment(name, uri, namespace, spec string) error DeleteEnvironment(name string) error - GetEnvironments() (app.EnvironmentSpecs, error) - GetEnvironment(name string) (*app.EnvironmentSpec, error) + GetEnvironments() (map[string]env.Env, error) + GetEnvironment(name string) (*env.Env, error) SetEnvironment(name, desiredName string) error - - // Spec API. - AppSpec() (*app.Spec, error) - WriteAppSpec(*app.Spec) error + GetDestination(envName string) (env.Destination, error) // Dependency/registry API. AddRegistry(name, protocol, uri, version string) (*registry.Spec, error) @@ -105,23 +103,6 @@ func Init(name, rootPath string, k8sSpecFlag, serverURI, namespace *string) (Man return initManager(name, rootPath, k8sSpecFlag, serverURI, namespace, gh, appFS) } -// isValidName returns true if a name (e.g., for an environment) is valid. -// Broadly, this means 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 init() { appFS = afero.NewOsFs() } diff --git a/metadata/lib/clusterspec_test.go b/metadata/lib/clusterspec_test.go index 5c095cbf..a0307acb 100644 --- a/metadata/lib/clusterspec_test.go +++ b/metadata/lib/clusterspec_test.go @@ -16,8 +16,11 @@ package lib import ( + "os" "path/filepath" "testing" + + "github.com/spf13/afero" ) type parseSuccess struct { @@ -25,13 +28,16 @@ type parseSuccess struct { target ClusterSpec } -var successTests = []parseSuccess{ - {"version:v1.7.1", &clusterSpecVersion{"v1.7.1"}}, - {"file:swagger.json", &clusterSpecFile{"swagger.json", testFS}}, - {"url:file:///some_file", &clusterSpecLive{"file:///some_file"}}, -} - func TestClusterSpecParsingSuccess(t *testing.T) { + testFS := afero.NewMemMapFs() + afero.WriteFile(testFS, blankSwagger, []byte(blankSwaggerData), os.ModePerm) + + var successTests = []parseSuccess{ + {"version:v1.7.1", &clusterSpecVersion{"v1.7.1"}}, + {"file:swagger.json", &clusterSpecFile{"swagger.json", testFS}}, + {"url:file:///some_file", &clusterSpecLive{"file:///some_file"}}, + } + for _, test := range successTests { parsed, err := ParseClusterSpec(test.input, testFS) if err != nil { @@ -80,7 +86,10 @@ var failureTests = []parseFailure{ } func TestClusterSpecParsingFailure(t *testing.T) { + for _, test := range failureTests { + testFS := afero.NewMemMapFs() + afero.WriteFile(testFS, blankSwagger, []byte(blankSwaggerData), os.ModePerm) _, err := ParseClusterSpec(test.input, testFS) if err == nil { t.Errorf("Cluster spec parse for '%s' should have failed, but succeeded", test.input) diff --git a/metadata/lib/lib.go b/metadata/lib/lib.go index 8be7d908..4feb6a79 100644 --- a/metadata/lib/lib.go +++ b/metadata/lib/lib.go @@ -19,6 +19,7 @@ import ( "fmt" "os" "path" + "path/filepath" str "github.com/ksonnet/ksonnet/strings" log "github.com/sirupsen/logrus" @@ -70,7 +71,7 @@ func NewManager(k8sSpecFlag string, fs afero.Fs, libPath string) (*Manager, erro // directory of a ksonnet project. The swagger and ksonnet-lib files are // unique to each Kubernetes API version. If the files already exist for a // specific Kubernetes API version, they won't be re-generated here. -func (m *Manager) GenerateLibData() error { +func (m *Manager) GenerateLibData(useVersionPath bool) error { if m.spec == nil { return fmt.Errorf("Uninitialized ClusterSpec") } @@ -85,8 +86,13 @@ func (m *Manager) GenerateLibData() error { return err } - versionPath := str.AppendToPath(m.libPath, m.K8sVersion) - ok, err := afero.DirExists(m.fs, string(versionPath)) + genPath := m.libPath + + if useVersionPath { + genPath = filepath.Join(m.libPath, m.K8sVersion) + } + + ok, err := afero.DirExists(m.fs, genPath) if err != nil { return err } @@ -95,7 +101,7 @@ func (m *Manager) GenerateLibData() error { return nil } - err = m.fs.MkdirAll(string(versionPath), os.FileMode(0755)) + err = m.fs.MkdirAll(genPath, os.FileMode(0755)) if err != nil { return err } @@ -106,22 +112,22 @@ func (m *Manager) GenerateLibData() error { }{ { // schema file - str.AppendToPath(versionPath, schemaFilename), + filepath.Join(genPath, schemaFilename), kl.Swagger, }, { // k8s file - str.AppendToPath(versionPath, k8sLibFilename), + filepath.Join(genPath, k8sLibFilename), kl.K8s, }, { // extensions file - str.AppendToPath(versionPath, ExtensionsLibFilename), + filepath.Join(genPath, ExtensionsLibFilename), kl.K, }, } - log.Infof("Generating ksonnet-lib data at path '%s'", versionPath) + log.Infof("Generating ksonnet-lib data at path '%s'", genPath) for _, a := range files { fileName := path.Base(string(a.path)) @@ -136,7 +142,7 @@ func (m *Manager) GenerateLibData() error { // GetLibPath returns the absolute path pointing to the directory with the // metadata files for the provided k8sVersion. -func (m *Manager) GetLibPath() (string, error) { +func (m *Manager) GetLibPath(useVersionPath bool) (string, error) { path := str.AppendToPath(m.libPath, m.K8sVersion) ok, err := afero.DirExists(m.fs, string(path)) if err != nil { @@ -146,7 +152,7 @@ func (m *Manager) GetLibPath() (string, error) { log.Debugf("Expected lib directory '%s' but was not found", m.K8sVersion) // create the directory - if err := m.GenerateLibData(); err != nil { + if err = m.GenerateLibData(useVersionPath); err != nil { return "", err } diff --git a/metadata/lib/lib_test.go b/metadata/lib/lib_test.go index 8e070e97..f48384f1 100644 --- a/metadata/lib/lib_test.go +++ b/metadata/lib/lib_test.go @@ -16,13 +16,12 @@ package lib import ( "fmt" - "io/ioutil" "os" + "path/filepath" "testing" "github.com/spf13/afero" - - str "github.com/ksonnet/ksonnet/strings" + "github.com/stretchr/testify/require" ) const ( @@ -40,51 +39,56 @@ const ( }` ) -var testFS = afero.NewMemMapFs() - -func init() { - afero.WriteFile(testFS, blankSwagger, []byte(blankSwaggerData), os.ModePerm) -} - func TestGenerateLibData(t *testing.T) { - specFlag := fmt.Sprintf("file:%s", blankSwagger) - libPath := "lib" - - libManager, err := NewManager(specFlag, testFS, libPath) - if err != nil { - t.Fatal("Failed to initialize lib.Manager") + cases := []struct { + name string + useVersionPath bool + basePath string + }{ + { + name: "use version path", + useVersionPath: true, + basePath: "v1.7.0", + }, + { + name: "don't use version path", + }, } - err = libManager.GenerateLibData() - if err != nil { - t.Fatal("Failed to generate lib data") - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testFS := afero.NewMemMapFs() + afero.WriteFile(testFS, blankSwagger, []byte(blankSwaggerData), os.ModePerm) - // Verify contents of lib. - versionPath := str.AppendToPath(libPath, "v1.7.0") + specFlag := fmt.Sprintf("file:%s", blankSwagger) + libPath := "lib" - schemaPath := str.AppendToPath(versionPath, schemaFilename) - bytes, err := afero.ReadFile(testFS, string(schemaPath)) - if err != nil { - t.Fatalf("Failed to read swagger file at '%s':\n%v", schemaPath, err) - } + libManager, err := NewManager(specFlag, testFS, libPath) + if err != nil { + t.Fatal("Failed to initialize lib.Manager") + } - if actualSwagger := string(bytes); actualSwagger != blankSwaggerData { - t.Fatalf("Expected swagger file at '%s' to have value: '%s', got: '%s'", schemaPath, blankSwaggerData, actualSwagger) - } + err = libManager.GenerateLibData(tc.useVersionPath) + if err != nil { + t.Fatal("Failed to generate lib data") + } - k8sLibPath := str.AppendToPath(versionPath, k8sLibFilename) - k8sLibBytes, err := afero.ReadFile(testFS, string(k8sLibPath)) - if err != nil { - t.Fatalf("Failed to read ksonnet-lib file at '%s':\n%v", k8sLibPath, err) - } + // Verify contents of lib. + genPath := filepath.Join(libPath, tc.basePath) + schemaPath := filepath.Join(genPath, "swagger.json") + extPath := filepath.Join(genPath, "k.libsonnet") + k8sPath := filepath.Join(genPath, "k8s.libsonnet") - blankK8sLib, err := ioutil.ReadFile("testdata/k8s.libsonnet") - if err != nil { - t.Fatalf("Failed to read testdata/k8s.libsonnet: %#v", err) + checkExists(t, testFS, schemaPath) + checkExists(t, testFS, extPath) + checkExists(t, testFS, k8sPath) + }) } - if actualK8sLib := string(k8sLibBytes); actualK8sLib != string(blankK8sLib) { - t.Fatalf("Expected swagger file at '%s' to have value: '%s', got: '%s'", k8sLibPath, blankK8sLib, actualK8sLib) - } +} + +func checkExists(t *testing.T, fs afero.Fs, path string) { + exists, err := afero.Exists(fs, path) + require.NoError(t, err) + require.True(t, exists, "%q did not exist", path) } diff --git a/metadata/lib/testdata/k8s.libsonnet b/metadata/lib/testdata/k8s.libsonnet deleted file mode 100644 index 6d4307b8..00000000 --- a/metadata/lib/testdata/k8s.libsonnet +++ /dev/null @@ -1,8 +0,0 @@ -{ - "__ksonnet": { - checksum: "f942f2f2b70d842504ffa21b502ad044be92110af159342a352bf1ed4c6221e2", - kubernetesVersion: "1.7.0", - }, - local hidden = { - }, -} \ No newline at end of file diff --git a/metadata/manager.go b/metadata/manager.go index 9bae3038..6ad56644 100644 --- a/metadata/manager.go +++ b/metadata/manager.go @@ -22,9 +22,11 @@ import ( "path/filepath" "github.com/ksonnet/ksonnet/component" + "github.com/ksonnet/ksonnet/env" "github.com/ksonnet/ksonnet/metadata/app" "github.com/ksonnet/ksonnet/metadata/registry" str "github.com/ksonnet/ksonnet/strings" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/spf13/afero" ) @@ -88,7 +90,21 @@ func findManager(p string, appFS afero.Fs) (*manager, error) { return nil, err } if exists { - return newManager(currBase, appFS) + m, err := newManager(currBase, appFS) + if err != nil { + return nil, err + } + + app, err := m.App() + if err != nil { + return nil, err + } + + if err = app.Init(); err != nil { + return nil, errors.Wrap(err, "initialize app schema") + } + + return m, nil } lastBase = currBase @@ -100,9 +116,10 @@ func findManager(p string, appFS afero.Fs) (*manager, error) { } func initManager(name, rootPath string, k8sSpecFlag, serverURI, namespace *string, incubatorReg registry.Manager, appFS afero.Fs) (*manager, error) { + log.Info("init manager") m, err := newManager(rootPath, appFS) if err != nil { - return nil, err + return nil, errors.Wrap(err, "create manager") } // Retrieve `registry.yaml`. @@ -139,7 +156,7 @@ func initManager(name, rootPath string, k8sSpecFlag, serverURI, namespace *strin } // Write out `incubator` registry spec. - registryPath := string(m.registryPath(incubatorReg)) + registryPath := m.registryPath(incubatorReg) err = afero.WriteFile(m.appFS, registryPath, registryYAMLData, defaultFilePermissions) if err != nil { return nil, errorOnCreateFailure(name, err) @@ -155,7 +172,7 @@ func newManager(rootPath string, appFS afero.Fs) (*manager, error) { } userRootPath := str.AppendToPath(usr.HomeDir, userKsonnetRootDir) - return &manager{ + m := &manager{ appFS: appFS, // Application paths. @@ -174,17 +191,32 @@ func newManager(rootPath string, appFS afero.Fs) (*manager, error) { // User-level paths. userKsonnetRootPath: userRootPath, pkgSrcCachePath: str.AppendToPath(userRootPath, pkgSrcCacheDir), - }, nil + } + + return m, nil } func (m *manager) Root() string { return m.rootPath } +func (m *manager) App() (app.App, error) { + return app.Load(m.appFS, m.rootPath) +} + func (m *manager) LibPaths() (envPath, vendorPath string) { return m.environmentsPath, m.vendorPath } +func (m *manager) GetDestination(envName string) (env.Destination, error) { + appEnv, err := m.GetEnvironment(envName) + if err != nil { + return env.Destination{}, err + } + + return appEnv.Destination, nil +} + func (m *manager) createUserDirTree() error { dirPaths := []string{ m.userKsonnetRootPath, @@ -203,7 +235,7 @@ func (m *manager) createUserDirTree() error { func (m *manager) createAppDirTree(name string, appYAMLData, baseLibData []byte, gh registry.Manager) error { exists, err := afero.DirExists(m.appFS, m.rootPath) if err != nil { - return fmt.Errorf("Could not check existance of directory '%s':\n%v", m.rootPath, err) + return fmt.Errorf("Could not check existence of directory '%s':\n%v", m.rootPath, err) } else if exists { return fmt.Errorf("Could not create app; directory '%s' already exists", m.rootPath) } @@ -221,7 +253,7 @@ func (m *manager) createAppDirTree(name string, appYAMLData, baseLibData []byte, for _, p := range dirPaths { log.Debugf("Creating directory '%s'", p) - if err := m.appFS.MkdirAll(string(p), defaultFolderPermissions); err != nil { + if err := m.appFS.MkdirAll(p, defaultFolderPermissions); err != nil { return errorOnCreateFailure(name, err) } } diff --git a/metadata/manager_test.go b/metadata/manager_test.go index aeb1e650..b566a18d 100644 --- a/metadata/manager_test.go +++ b/metadata/manager_test.go @@ -21,6 +21,7 @@ import ( "path" "testing" + "github.com/ksonnet/ksonnet/metadata/app" str "github.com/ksonnet/ksonnet/strings" "github.com/spf13/afero" ) @@ -48,191 +49,210 @@ const ( ` ) -var testFS = afero.NewMemMapFs() +func withFs(fn func(afero.Fs)) { + ogAppLibUpdater := app.LibUpdater + app.LibUpdater = app.StubUpdateLibData -func init() { - afero.WriteFile(testFS, blankSwagger, []byte(blankSwaggerData), os.ModePerm) + defer func() { + app.LibUpdater = ogAppLibUpdater + }() + + fs := afero.NewMemMapFs() + afero.WriteFile(fs, blankSwagger, []byte(blankSwaggerData), os.ModePerm) + fn(fs) } func TestInitSuccess(t *testing.T) { - specFlag := fmt.Sprintf("file:%s", blankSwagger) - - appPath := "/fromEmptySwagger" - reg := newMockRegistryManager("incubator") - _, err := initManager("fromEmptySwagger", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, testFS) - if err != nil { - t.Fatalf("Failed to init cluster spec: %v", err) - } - - // Verify path locations. - defaultEnvDir := str.AppendToPath(environmentsDir, defaultEnvName) - paths := []string{ - ksonnetDir, - libDir, - componentsDir, - environmentsDir, - vendorDir, - defaultEnvDir, - } - - for _, p := range paths { - path := str.AppendToPath(appPath, p) - exists, err := afero.DirExists(testFS, path) + withFs(func(fs afero.Fs) { + specFlag := fmt.Sprintf("file:%s", blankSwagger) + + appPath := "/fromEmptySwagger" + reg := newMockRegistryManager("incubator") + _, err := initManager("fromEmptySwagger", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, fs) if err != nil { - t.Fatalf("Expected to create directory '%s', but failed:\n%v", p, err) - } else if !exists { - t.Fatalf("Expected to create directory '%s', but failed", path) + t.Fatalf("Failed to init cluster spec: %v", err) } - } - paths = []string{ - pkgSrcCacheDir, - } + // Verify path locations. + defaultEnvDir := str.AppendToPath(environmentsDir, defaultEnvName) + paths := []string{ + ksonnetDir, + libDir, + componentsDir, + environmentsDir, + vendorDir, + defaultEnvDir, + } + + for _, p := range paths { + path := str.AppendToPath(appPath, p) + exists, err := afero.DirExists(fs, path) + if err != nil { + t.Fatalf("Expected to create directory '%s', but failed:\n%v", p, err) + } else if !exists { + t.Fatalf("Expected to create directory '%s', but failed", path) + } + } - usr, err := user.Current() - if err != nil { - t.Fatalf("Could not get user information:\n%v", err) - } - userRootPath := str.AppendToPath(usr.HomeDir, userKsonnetRootDir) + paths = []string{ + pkgSrcCacheDir, + } - for _, p := range paths { - path := str.AppendToPath(userRootPath, p) - exists, err := afero.DirExists(testFS, path) + usr, err := user.Current() if err != nil { - t.Fatalf("Expected to create directory '%s', but failed:\n%v", p, err) - } else if !exists { - t.Fatalf("Expected to create directory '%s', but failed", path) - } - } - - // Verify contents of metadata. - envPath := str.AppendToPath(appPath, environmentsDir) - - componentParamsPath := str.AppendToPath(appPath, componentsDir, componentParamsFile) - componentParamsBytes, err := afero.ReadFile(testFS, componentParamsPath) - if err != nil { - t.Fatalf("Failed to read params.libsonnet file at '%s':\n%v", componentParamsPath, err) - } else if len(componentParamsBytes) == 0 { - t.Fatalf("Expected params.libsonnet at '%s' to be non-empty", componentParamsPath) - } - - baseLibsonnetPath := str.AppendToPath(envPath, baseLibsonnetFile) - baseLibsonnetBytes, err := afero.ReadFile(testFS, baseLibsonnetPath) - if err != nil { - t.Fatalf("Failed to read base.libsonnet file at '%s':\n%v", baseLibsonnetPath, err) - } else if len(baseLibsonnetBytes) == 0 { - t.Fatalf("Expected base.libsonnet at '%s' to be non-empty", baseLibsonnetPath) - } - - appYAMLPath := str.AppendToPath(appPath, appYAMLFile) - appYAMLBytes, err := afero.ReadFile(testFS, appYAMLPath) - if err != nil { - t.Fatalf("Failed to read app.yaml file at '%s':\n%v", appYAMLPath, err) - } else if len(appYAMLBytes) == 0 { - t.Fatalf("Expected app.yaml at '%s' to be non-empty", appYAMLPath) - } - - registryYAMLPath := str.AppendToPath(appPath, registriesDir, "incubator", "master.yaml") - registryYAMLBytes, err := afero.ReadFile(testFS, registryYAMLPath) - if err != nil { - t.Fatalf("Failed to read registry.yaml file at '%s':\n%v", registryYAMLPath, err) - } else if len(registryYAMLBytes) == 0 { - t.Fatalf("Expected registry.yaml at '%s' to be non-empty", registryYAMLPath) - } + t.Fatalf("Could not get user information:\n%v", err) + } + userRootPath := str.AppendToPath(usr.HomeDir, userKsonnetRootDir) + + for _, p := range paths { + path := str.AppendToPath(userRootPath, p) + exists, err := afero.DirExists(fs, path) + if err != nil { + t.Fatalf("Expected to create directory '%s', but failed:\n%v", p, err) + } else if !exists { + t.Fatalf("Expected to create directory '%s', but failed", path) + } + } + + // Verify contents of metadata. + envPath := str.AppendToPath(appPath, environmentsDir) + + componentParamsPath := str.AppendToPath(appPath, componentsDir, componentParamsFile) + componentParamsBytes, err := afero.ReadFile(fs, componentParamsPath) + if err != nil { + t.Fatalf("Failed to read params.libsonnet file at '%s':\n%v", componentParamsPath, err) + } else if len(componentParamsBytes) == 0 { + t.Fatalf("Expected params.libsonnet at '%s' to be non-empty", componentParamsPath) + } + + baseLibsonnetPath := str.AppendToPath(envPath, baseLibsonnetFile) + baseLibsonnetBytes, err := afero.ReadFile(fs, baseLibsonnetPath) + if err != nil { + t.Fatalf("Failed to read base.libsonnet file at '%s':\n%v", baseLibsonnetPath, err) + } else if len(baseLibsonnetBytes) == 0 { + t.Fatalf("Expected base.libsonnet at '%s' to be non-empty", baseLibsonnetPath) + } + + appYAMLPath := str.AppendToPath(appPath, appYAMLFile) + appYAMLBytes, err := afero.ReadFile(fs, appYAMLPath) + if err != nil { + t.Fatalf("Failed to read app.yaml file at '%s':\n%v", appYAMLPath, err) + } else if len(appYAMLBytes) == 0 { + t.Fatalf("Expected app.yaml at '%s' to be non-empty", appYAMLPath) + } + + registryYAMLPath := str.AppendToPath(appPath, registriesDir, "incubator", "master.yaml") + registryYAMLBytes, err := afero.ReadFile(fs, registryYAMLPath) + if err != nil { + t.Fatalf("Failed to read registry.yaml file at '%s':\n%v", registryYAMLPath, err) + } else if len(registryYAMLBytes) == 0 { + t.Fatalf("Expected registry.yaml at '%s' to be non-empty", registryYAMLPath) + } + }) } func TestFindSuccess(t *testing.T) { - findSuccess := func(t *testing.T, appDir, currDir string) { - m, err := findManager(currDir, testFS) - if err != nil { - t.Fatalf("Failed to find manager at path '%s':\n%v", currDir, err) - } else if m.rootPath != appDir { - t.Fatalf("Found manager at incorrect path '%s', expected '%s'", m.rootPath, appDir) + withFs(func(fs afero.Fs) { + findSuccess := func(t *testing.T, appDir, currDir string) { + m, err := findManager(currDir, fs) + if err != nil { + t.Fatalf("Failed to find manager at path '%s':\n%v", currDir, err) + } else if m.rootPath != appDir { + t.Fatalf("Found manager at incorrect path '%s', expected '%s'", m.rootPath, appDir) + } } - } - specFlag := fmt.Sprintf("file:%s", blankSwagger) + specFlag := fmt.Sprintf("file:%s", blankSwagger) - appPath := "/findSuccess" - reg := newMockRegistryManager("incubator") - _, err := initManager("findSuccess", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, testFS) - if err != nil { - t.Fatalf("Failed to init cluster spec: %v", err) - } + appPath := "/findSuccess" + reg := newMockRegistryManager("incubator") + _, err := initManager("findSuccess", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, fs) + if err != nil { + t.Fatalf("Failed to init cluster spec: %v", err) + } - findSuccess(t, appPath, appPath) + findSuccess(t, appPath, appPath) - components := str.AppendToPath(appPath, componentsDir) - findSuccess(t, appPath, components) + components := str.AppendToPath(appPath, componentsDir) + findSuccess(t, appPath, components) - // Create empty app file. - appFile := str.AppendToPath(components, "app.jsonnet") - f, err := testFS.OpenFile(appFile, os.O_RDONLY|os.O_CREATE, 0777) - if err != nil { - t.Fatalf("Failed to touch app file '%s'\n%v", appFile, err) - } - f.Close() + // Create empty app file. + appFile := str.AppendToPath(components, "app.jsonnet") + f, err := fs.OpenFile(appFile, os.O_RDONLY|os.O_CREATE, 0777) + if err != nil { + t.Fatalf("Failed to touch app file '%s'\n%v", appFile, err) + } + f.Close() - findSuccess(t, appPath, appFile) + findSuccess(t, appPath, appFile) + }) } func TestLibPaths(t *testing.T) { - appName := "test-lib-paths" - expectedVendorPath := path.Join(appName, vendorDir) - expectedEnvPath := path.Join(appName, environmentsDir) - m := mockEnvironments(t, appName) - - envPath, vendorPath := m.LibPaths() - if envPath != expectedEnvPath { - t.Fatalf("Expected env path to be:\n '%s'\n, got:\n '%s'", expectedEnvPath, envPath) - } - if vendorPath != expectedVendorPath { - t.Fatalf("Expected vendor lib path to be:\n '%s'\n, got:\n '%s'", expectedVendorPath, vendorPath) - } + withFs(func(fs afero.Fs) { + appName := "test-lib-paths" + expectedVendorPath := path.Join("/", appName, vendorDir) + expectedEnvPath := path.Join("/", appName, environmentsDir) + m := mockEnvironments(t, fs, appName) + + envPath, vendorPath := m.LibPaths() + if envPath != expectedEnvPath { + t.Fatalf("Expected env path to be:\n '%s'\n, got:\n '%s'", expectedEnvPath, envPath) + } + if vendorPath != expectedVendorPath { + t.Fatalf("Expected vendor lib path to be:\n '%s'\n, got:\n '%s'", expectedVendorPath, vendorPath) + } + }) } func TestMakeEnvPaths(t *testing.T) { - appName := "test-env-paths" - expectedMainPath := path.Join(appName, environmentsDir, mockEnvName, envFileName) - expectedParamsPath := path.Join(appName, environmentsDir, mockEnvName, paramsFileName) - m := mockEnvironments(t, appName) - - mainPath, paramsPath := m.makeEnvPaths(mockEnvName) - - if mainPath != expectedMainPath { - t.Fatalf("Expected environment main path to be:\n '%s'\n, got:\n '%s'", expectedMainPath, mainPath) - } - if paramsPath != expectedParamsPath { - t.Fatalf("Expected environment params path to be:\n '%s'\n, got:\n '%s'", expectedParamsPath, paramsPath) - } + withFs(func(fs afero.Fs) { + appName := "test-env-paths" + expectedMainPath := path.Join("/", appName, environmentsDir, mockEnvName, envFileName) + expectedParamsPath := path.Join("/", appName, environmentsDir, mockEnvName, paramsFileName) + m := mockEnvironments(t, fs, appName) + + mainPath, paramsPath := m.makeEnvPaths(mockEnvName) + + if mainPath != expectedMainPath { + t.Fatalf("Expected environment main path to be:\n '%s'\n, got:\n '%s'", expectedMainPath, mainPath) + } + if paramsPath != expectedParamsPath { + t.Fatalf("Expected environment params path to be:\n '%s'\n, got:\n '%s'", expectedParamsPath, paramsPath) + } + }) } func TestFindFailure(t *testing.T) { - findFailure := func(t *testing.T, currDir string) { - _, err := findManager(currDir, testFS) - if err == nil { - t.Fatalf("Expected to fail to find ksonnet app in '%s', but succeeded", currDir) + withFs(func(fs afero.Fs) { + findFailure := func(t *testing.T, currDir string) { + _, err := findManager(currDir, fs) + if err == nil { + t.Fatalf("Expected to fail to find ksonnet app in '%s', but succeeded", currDir) + } } - } - findFailure(t, "/") - findFailure(t, "/fakePath") - findFailure(t, "") + findFailure(t, "/") + findFailure(t, "/fakePath") + findFailure(t, "") + }) } func TestDoubleNewFailure(t *testing.T) { - specFlag := fmt.Sprintf("file:%s", blankSwagger) - - appPath := "/doubleNew" - reg := newMockRegistryManager("incubator") - _, err := initManager("doubleNew", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, testFS) - if err != nil { - t.Fatalf("Failed to init cluster spec: %v", err) - } - - targetErr := fmt.Sprintf("Could not create app; directory '%s' already exists", appPath) - _, err = initManager("doubleNew", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, testFS) - if err == nil || err.Error() != targetErr { - t.Fatalf("Expected to fail to create app with message '%s', got '%s'", targetErr, err.Error()) - } + withFs(func(fs afero.Fs) { + specFlag := fmt.Sprintf("file:%s", blankSwagger) + + appPath := "/doubleNew" + reg := newMockRegistryManager("incubator") + _, err := initManager("doubleNew", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, fs) + if err != nil { + t.Fatalf("Failed to init cluster spec: %v", err) + } + + targetErr := fmt.Sprintf("Could not create app; directory '%s' already exists", appPath) + _, err = initManager("doubleNew", appPath, &specFlag, &mockAPIServer, &mockNamespace, reg, fs) + if err == nil || err.Error() != targetErr { + t.Fatalf("Expected to fail to create app with message '%s', got '%s'", targetErr, err.Error()) + } + }) } diff --git a/metadata/registry.go b/metadata/registry.go index 7c8f7652..87a09792 100644 --- a/metadata/registry.go +++ b/metadata/registry.go @@ -17,7 +17,7 @@ import ( // AddRegistry adds a registry with `name`, `protocol`, and `uri` to // the current ksonnet application. func (m *manager) AddRegistry(name, protocol, uri, version string) (*registry.Spec, error) { - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { return nil, err } @@ -75,7 +75,7 @@ func (m *manager) GetRegistry(name string) (*registry.Spec, string, error) { func (m *manager) GetPackage(registryName, libID string) (*parts.Spec, error) { // Retrieve application specification. - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { return nil, err } @@ -109,7 +109,7 @@ func (m *manager) GetPackage(registryName, libID string) (*parts.Spec, error) { func (m *manager) GetDependency(libName string) (*parts.Spec, error) { // Retrieve application specification. - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { return nil, err } @@ -144,7 +144,7 @@ func (m *manager) GetDependency(libName string) (*parts.Spec, error) { func (m *manager) CacheDependency(registryName, libID, libName, libVersion string) (*parts.Spec, error) { // Retrieve application specification. - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { return nil, err } @@ -254,7 +254,7 @@ func (m *manager) GetPrototypesForDependency(registryName, libID string) (protot } func (m *manager) GetAllPrototypes() (prototype.SpecificationSchemas, error) { - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { return nil, err } @@ -280,7 +280,7 @@ func (m *manager) registryPath(regManager registry.Manager) string { } func (m *manager) getRegistryManager(registryName string) (registry.Manager, string, error) { - appSpec, err := m.AppSpec() + appSpec, err := app.Read(m.appFS, m.rootPath) if err != nil { return nil, "", err } diff --git a/pkg/kubecfg/env.go b/pkg/kubecfg/env.go index 5338c3df..f9bc84a0 100644 --- a/pkg/kubecfg/env.go +++ b/pkg/kubecfg/env.go @@ -21,8 +21,7 @@ import ( "sort" "strings" - "github.com/ksonnet/ksonnet/metadata/app" - + "github.com/ksonnet/ksonnet/env" "github.com/ksonnet/ksonnet/metadata" str "github.com/ksonnet/ksonnet/strings" ) @@ -83,9 +82,9 @@ func (c *EnvListCmd) Run(out io.Writer) error { return err } - var envs []app.EnvironmentSpec + var envs []env.Env for _, e := range envMap { - envs = append(envs, *e) + envs = append(envs, e) } // Sort environments by ascending alphabetical name @@ -101,7 +100,11 @@ func (c *EnvListCmd) Run(out io.Writer) error { } for _, env := range envs { - rows = append(rows, []string{env.Name, env.KubernetesVersion, env.Destination.Namespace, env.Destination.Server}) + rows = append(rows, []string{ + env.Name, + env.KubernetesVersion, + env.Destination.Namespace(), + env.Destination.Server()}) } formattedEnvsList, err := str.PadRows(rows) diff --git a/pkg/kubecfg/param.go b/pkg/kubecfg/param.go index a55639fe..bb80d975 100644 --- a/pkg/kubecfg/param.go +++ b/pkg/kubecfg/param.go @@ -24,11 +24,13 @@ import ( "strconv" "strings" + "github.com/ksonnet/ksonnet/component" param "github.com/ksonnet/ksonnet/metadata/params" str "github.com/ksonnet/ksonnet/strings" + "github.com/olekukonko/tablewriter" "github.com/pkg/errors" + "github.com/spf13/afero" - "github.com/fatih/color" log "github.com/sirupsen/logrus" ) @@ -113,13 +115,20 @@ const ( // ParamListCmd stores the information necessary display component or // environment parameters type ParamListCmd struct { + fs afero.Fs + root string component string env string + nsName string } // NewParamListCmd acts as a constructor for ParamListCmd. -func NewParamListCmd(component, env string) *ParamListCmd { - return &ParamListCmd{component: component, env: env} +func NewParamListCmd(component, env, nsName string) *ParamListCmd { + return &ParamListCmd{ + component: component, + env: env, + nsName: nsName, + } } // Run executes the displaying of params. @@ -136,7 +145,7 @@ func (c *ParamListCmd) Run(out io.Writer) error { var params map[string]param.Params if len(c.env) != 0 { - params, err = manager.GetEnvironmentParams(c.env) + params, err = manager.GetEnvironmentParams(c.env, c.nsName) if err != nil { return err } @@ -210,6 +219,8 @@ func outputParams(params map[string]param.Params, out io.Writer) error { // ParamDiffCmd stores the information necessary to diff between environment // parameters. type ParamDiffCmd struct { + fs afero.Fs + root string env1 string env2 string @@ -217,8 +228,14 @@ type ParamDiffCmd struct { } // NewParamDiffCmd acts as a constructor for ParamDiffCmd. -func NewParamDiffCmd(env1, env2, component string) *ParamDiffCmd { - return &ParamDiffCmd{env1: env1, env2: env2, component: component} +func NewParamDiffCmd(fs afero.Fs, root, env1, env2, componentName string) *ParamDiffCmd { + return &ParamDiffCmd{ + fs: fs, + root: root, + env1: env1, + env2: env2, + component: componentName, + } } type paramDiffRecord struct { @@ -235,19 +252,21 @@ func (c *ParamDiffCmd) Run(out io.Writer) error { return err } - params1, err := manager.GetEnvironmentParams(c.env1) + ns, componentName := component.ExtractNamespacedComponent(c.fs, c.root, c.component) + + params1, err := manager.GetEnvironmentParams(c.env1, ns.Path) if err != nil { return err } - params2, err := manager.GetEnvironmentParams(c.env2) + params2, err := manager.GetEnvironmentParams(c.env2, ns.Path) if err != nil { return err } if len(c.component) != 0 { - params1 = map[string]param.Params{c.component: params1[c.component]} - params2 = map[string]param.Params{c.component: params2[c.component]} + params1 = map[string]param.Params{componentName: params1[componentName]} + params2 = map[string]param.Params{componentName: params2[componentName]} } if reflect.DeepEqual(params1, params2) { @@ -255,127 +274,97 @@ func (c *ParamDiffCmd) Run(out io.Writer) error { return nil } - records := diffParams(params1, params2) - - // - // Format each component parameter information for pretty printing. - // Each component will be outputted alphabetically like the following: - // - // COMPONENT PARAM dev prod - // bar name "bar-dev" "bar" - // foo replicas 1 - // - - maxComponentLen := len(paramComponentHeader) - for _, k := range records { - if l := len(k.component); l > maxComponentLen { - maxComponentLen = l - } - } + componentNames := collectComponents(params1, params2) - maxParamLen := len(paramNameHeader) + maxComponentLen + 1 - for _, k := range records { - if l := len(k.param) + maxComponentLen + 1; l > maxParamLen { - maxParamLen = l - } - } + var rows [][]string + for _, componentName := range componentNames { + paramNames := collectParams(params1[componentName], params2[componentName]) - maxEnvLen := len(c.env1) + maxParamLen + 1 - for _, k := range records { - if l := len(k.value1) + maxParamLen + 1; l > maxEnvLen { - maxEnvLen = l - } - } + for _, paramName := range paramNames { + var v1, v2 string + var ok bool + var p param.Params - componentSpacing := strings.Repeat(" ", maxComponentLen-len(paramComponentHeader)+1) - nameSpacing := strings.Repeat(" ", maxParamLen-maxComponentLen-len(paramNameHeader)) - envSpacing := strings.Repeat(" ", maxEnvLen-maxParamLen-len(c.env1)) - - // print headers - fmt.Fprintln(out, paramComponentHeader+componentSpacing+ - paramNameHeader+nameSpacing+c.env1+envSpacing+c.env2) - fmt.Fprintln(out, strings.Repeat("=", len(paramComponentHeader))+componentSpacing+ - strings.Repeat("=", len(paramNameHeader))+nameSpacing+ - strings.Repeat("=", len(c.env1))+envSpacing+ - strings.Repeat("=", len(c.env2))) - - // print body - for _, k := range records { - componentSpacing = strings.Repeat(" ", maxComponentLen-len(k.component)+1) - nameSpacing = strings.Repeat(" ", maxParamLen-maxComponentLen-len(k.param)) - envSpacing = strings.Repeat(" ", maxEnvLen-maxParamLen-len(k.value1)) - line := fmt.Sprint(k.component + componentSpacing + k.param + nameSpacing + k.value1 + envSpacing + k.value2) - if len(k.value1) == 0 { - color.New(color.BgGreen).Fprint(out, line) - fmt.Fprintln(out) - } else if len(k.value2) == 0 { - color.New(color.BgRed).Fprint(out, line) - fmt.Fprintln(out) - } else if k.value1 != k.value2 { - color.New(color.BgYellow).Fprint(out, line) - fmt.Fprintln(out) - } else { - fmt.Fprintln(out, line) + if p, ok = params1[componentName]; ok { + v1 = p[paramName] + } + + if p, ok = params2[componentName]; ok { + v2 = p[paramName] + } + + row := []string{ + componentName, + paramName, + v1, + v2, + } + + rows = append(rows, row) } } + printTable([]string{"COMPONENT", "PARAM", c.env1, c.env2}, rows) return nil } -func diffParams(params1, params2 map[string]param.Params) []*paramDiffRecord { - var records []*paramDiffRecord +func collectComponents(param1, param2 map[string]param.Params) []string { + m := make(map[string]bool) + for k := range param1 { + m[k] = true + } + for k := range param2 { + m[k] = true + } - for c := range params1 { - if _, contains := params2[c]; !contains { - // env2 doesn't have this component, add all params from env1 for this component - for p := range params2[c] { - records = addRecord(records, c, p, params1[c][p], "") - } - } else { - // has same component -- need to compare params - for p := range params1[c] { - if _, hasParam := params2[c][p]; !hasParam { - // env2 doesn't have this param, add a record with the param value from env1 - records = addRecord(records, c, p, params1[c][p], "") - } else { - // env2 has this param too, add a record with both param values - records = addRecord(records, c, p, params1[c][p], params2[c][p]) - } - } - // add remaining records for params that env2 has that env1 does not for this component - for p := range params2[c] { - if _, hasParam := params1[c][p]; !hasParam { - records = addRecord(records, c, p, "", params2[c][p]) - } - } - } + var names []string + + for k := range m { + names = append(names, k) } - // add remaining records where env2 contains a component that env1 does not - for c := range params2 { - if _, contains := params1[c]; !contains { - for p := range params2[c] { - records = addRecord(records, c, p, "", params2[c][p]) - } - } + sort.Strings(names) + + return names +} + +func collectParams(param1, param2 param.Params) []string { + m := make(map[string]bool) + for k := range param1 { + m[k] = true + } + for k := range param2 { + m[k] = true } - sort.Slice(records, func(i, j int) bool { - if records[i].component == records[j].component { - return records[i].param < records[j].param - } - return records[i].component < records[j].component - }) + var names []string + + for k := range m { + names = append(names, k) + } - return records + sort.Strings(names) + + return names } -func addRecord(records []*paramDiffRecord, component, param, value1, value2 string) []*paramDiffRecord { - records = append(records, ¶mDiffRecord{ - component: component, - param: param, - value1: value1, - value2: value2, - }) - return records +func printTable(headers []string, data [][]string) { + headerLens := make([]int, len(headers)) + for i := range headers { + headerLens[i] = len(headers[i]) + } + + for i := range headerLens { + headers[i] = fmt.Sprintf("%s\n%s", headers[i], strings.Repeat("=", headerLens[i])) + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader(headers) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetRowLine(false) + table.SetBorder(false) + table.AppendBulk(data) + table.Render() } diff --git a/pkg/kubecfg/param_test.go b/pkg/kubecfg/param_test.go index fb4bf2d7..797cbaba 100644 --- a/pkg/kubecfg/param_test.go +++ b/pkg/kubecfg/param_test.go @@ -18,71 +18,9 @@ package kubecfg import ( "testing" - param "github.com/ksonnet/ksonnet/metadata/params" - "github.com/stretchr/testify/require" ) -func TestDiffParams(t *testing.T) { - tests := []struct { - params1 map[string]param.Params - params2 map[string]param.Params - expected []*paramDiffRecord - }{ - { - map[string]param.Params{ - "bar": param.Params{"replicas": "4"}, - "foo": param.Params{"replicas": "1", "name": `"foo"`}, - }, - map[string]param.Params{ - "bar": param.Params{"replicas": "3"}, - "foo": param.Params{"name": `"foo-dev"`, "replicas": "1"}, - "baz": param.Params{"name": `"baz"`, "replicas": "4"}, - }, - []*paramDiffRecord{ - ¶mDiffRecord{ - component: "bar", - param: "replicas", - value1: "4", - value2: "3", - }, - ¶mDiffRecord{ - component: "baz", - param: "name", - value1: "", - value2: `"baz"`, - }, - ¶mDiffRecord{ - component: "baz", - param: "replicas", - value1: "", - value2: "4", - }, - ¶mDiffRecord{ - component: "foo", - param: "name", - value1: `"foo"`, - value2: `"foo-dev"`, - }, - ¶mDiffRecord{ - component: "foo", - param: "replicas", - value1: "1", - value2: "1", - }, - }, - }, - } - - for _, s := range tests { - records := diffParams(s.params1, s.params2) - require.Equal(t, len(records), len(s.expected), "Record lengths not equivalent") - for i, record := range records { - require.EqualValues(t, *s.expected[i], *record) - } - } -} - func TestSanitizeParamValue(t *testing.T) { tests := []struct { value string diff --git a/testdata/testapp/app.yaml b/testdata/testapp/app.yaml index 9dc2c5f3..7a011823 100644 --- a/testdata/testapp/app.yaml +++ b/testdata/testapp/app.yaml @@ -1,4 +1,4 @@ -apiVersion: 0.0.1 +apiVersion: 0.1.0 kind: ksonnet.io/app name: test-app registries: -- GitLab