From f86667a6410dc108d168bb1fa94ead220805d18f Mon Sep 17 00:00:00 2001 From: Jessica Yuen <im.jessicayuen@gmail.com> Date: Wed, 13 Sep 2017 13:41:34 -0700 Subject: [PATCH] Add subcommand 'env set' 'env set <name>' sets environment fields such as the name, and cluster URI. It currently accepts the flags '--name' and '--uri'. Changing the name of an environment will also update the directory structure in 'environments'. --- cmd/env.go | 62 +++++++++++++-- cmd/init.go | 7 +- cmd/root.go | 1 + metadata/environment.go | 146 +++++++++++++++++++++++++++-------- metadata/environment_test.go | 77 ++++++++++++++---- metadata/interface.go | 4 +- metadata/manager.go | 31 ++++---- pkg/kubecfg/env.go | 45 ++++++++--- 8 files changed, 291 insertions(+), 82 deletions(-) diff --git a/cmd/env.go b/cmd/env.go index b128a259..04b6ac65 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -25,14 +25,26 @@ import ( "github.com/ksonnet/kubecfg/pkg/kubecfg" ) +const ( + flagEnvName = "name" + flagEnvURI = "uri" +) + func init() { RootCmd.AddCommand(envCmd) envCmd.AddCommand(envAddCmd) envCmd.AddCommand(envRmCmd) envCmd.AddCommand(envListCmd) + envCmd.AddCommand(envSetCmd) + // TODO: We need to make this default to checking the `kubeconfig` file. envAddCmd.PersistentFlags().String(flagAPISpec, "version:v1.7.0", "Manually specify API version from OpenAPI schema, cluster, or Kubernetes version") + + envSetCmd.PersistentFlags().String(flagEnvName, "", + "Specify name to rename environment to. Name must not already exist") + envSetCmd.PersistentFlags().String(flagEnvURI, "", + "Specify URI to point environment cluster to a new location") } var envCmd = &cobra.Command{ @@ -160,14 +172,54 @@ var envListCmd = &cobra.Command{ return err } - formattedEnvsString, err := c.Run() + return c.Run(cmd.OutOrStdout()) + }, + Long: `List all environments within a ksonnet project. This will +display the name and the URI of each environment within the ksonnet project.`, +} + +var envSetCmd = &cobra.Command{ + Use: "set <env-name> [parameter-flags]", + Short: "Set environment fields such as the name, and cluster URI.", + RunE: func(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + if len(args) != 1 { + return fmt.Errorf("'env set' takes a single argument, that is the name of the environment") + } + + envName := args[0] + + appDir, err := os.Getwd() if err != nil { return err } - fmt.Print(formattedEnvsString) + appRoot := metadata.AbsPath(appDir) - return nil + desiredEnvName, err := flags.GetString(flagEnvName) + if err != nil { + return err + } + + desiredEnvURI, err := flags.GetString(flagEnvURI) + if err != nil { + return err + } + + c, err := kubecfg.NewEnvSetCmd(envName, desiredEnvName, desiredEnvURI, appRoot) + if err != nil { + return err + } + + return c.Run() }, - Long: `List all environments within a ksonnet project. This will -display the name and the URI of each environment within the ksonnet project.`, + Long: `Set environment fields such as the name, and cluster URI. Changing +the name of an environment will also update the directory structure in +'environments'. +`, + Example: ` # Updates the URI of the environment 'us-west/staging'. + ksonnet env set us-west/staging --uri=http://example.com + + # Updates both the name and the URI of the environment 'us-west/staging'. + # Updating the name will update the directory structure in 'environments' + ksonnet env set us-west/staging --uri=http://example.com --name=us-east/staging`, } diff --git a/cmd/init.go b/cmd/init.go index 384080ad..250ca7d6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -25,14 +25,11 @@ import ( "github.com/spf13/cobra" ) -const ( - flagAPISpec = "api-spec" -) - func init() { RootCmd.AddCommand(initCmd) // TODO: We need to make this default to checking the `kubeconfig` file. - initCmd.PersistentFlags().String(flagAPISpec, "version:v1.7.0", "Manually specify API version from OpenAPI schema, cluster, or Kubernetes version") + initCmd.PersistentFlags().String(flagAPISpec, "version:v1.7.0", + "Manually specify API version from OpenAPI schema, cluster, or Kubernetes version") } var initCmd = &cobra.Command{ diff --git a/cmd/root.go b/cmd/root.go index 7b8c2036..46e8d74d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -51,6 +51,7 @@ const ( flagTlaVarFile = "tla-str-file" flagResolver = "resolve-images" flagResolvFail = "resolve-images-error" + flagAPISpec = "api-spec" // For use in the commands (e.g., diff, apply, delete) that require either an // environment or the -f flag. diff --git a/metadata/environment.go b/metadata/environment.go index 78ef4493..24f184e4 100644 --- a/metadata/environment.go +++ b/metadata/environment.go @@ -17,9 +17,10 @@ package metadata import ( "encoding/json" - "errors" + "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/spf13/afero" @@ -37,17 +38,28 @@ const ( specFilename = "spec.json" ) +// Environment represents all fields of a ksonnet environment type Environment struct { Path string Name string URI string } +// EnvironmentSpec represents the contents in spec.json. type EnvironmentSpec struct { URI string `json:"uri"` } -func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec, extensionsLibData, k8sLibData []byte) error { +func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec) error { + extensionsLibData, k8sLibData, specData, err := m.generateKsonnetLibData(spec) + if err != nil { + return err + } + + return m.createEnvironment(name, uri, extensionsLibData, k8sLibData, specData) +} + +func (m *manager) createEnvironment(name, uri string, extensionsLibData, k8sLibData, specData []byte) error { exists, err := m.environmentExists(name) if err != nil { return err @@ -56,14 +68,13 @@ func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec, extensio return fmt.Errorf("Environment '%s' already exists", name) } - envPath := appendToAbsPath(m.environmentsDir, name) - err = m.appFS.MkdirAll(string(envPath), os.ModePerm) - if err != nil { - return err + // ensure environment name does not contain punctuation + if !isValidName(name) { + return fmt.Errorf("Environment '%s' is not valid; must not contain punctuation or trailing slashes", name) } - // Get cluster specification data, possibly from the network. - specData, err := spec.data() + envPath := appendToAbsPath(m.environmentsPath, name) + err = m.appFS.MkdirAll(string(envPath), os.ModePerm) if err != nil { return err } @@ -98,23 +109,15 @@ func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec, extensio } func (m *manager) DeleteEnvironment(name string) error { - envPath := string(appendToAbsPath(m.environmentsDir, name)) + envPath := string(appendToAbsPath(m.environmentsPath, name)) - envs, err := m.GetEnvironments() + // Check whether this environment exists + envExists, err := m.environmentExists(name) if err != nil { return err } - - // Check whether this environment exists - envExists := false - for _, env := range envs { - if env.Path == envPath { - envExists = true - break - } - } if !envExists { - return errors.New("Environment \"" + name + "\" does not exist.") + return fmt.Errorf("Environment '%s' does not exist", name) } // Remove the directory and all files within the environment path. @@ -124,11 +127,10 @@ func (m *manager) DeleteEnvironment(name string) error { } // Need to ensure empty parent directories are also removed. - dirs := strings.Split(name, "/") parentDir := name - for i := len(dirs) - 2; i >= 0; i-- { - parentDir = strings.TrimSuffix(parentDir, "/"+dirs[i+1]) - parentPath := string(appendToAbsPath(m.environmentsDir, parentDir)) + for parentDir != "." { + parentDir = filepath.Dir(parentDir) + parentPath := string(appendToAbsPath(m.environmentsPath, parentDir)) isEmpty, err := afero.IsEmpty(m.appFS, parentPath) if err != nil { @@ -148,7 +150,7 @@ func (m *manager) DeleteEnvironment(name string) error { func (m *manager) GetEnvironments() ([]Environment, error) { envs := []Environment{} - err := afero.Walk(m.appFS, string(m.environmentsDir), func(path string, f os.FileInfo, err error) error { + err := afero.Walk(m.appFS, string(m.environmentsPath), func(path string, f os.FileInfo, err error) error { isDir, err := afero.IsDir(m.appFS, path) if err != nil { return err @@ -162,7 +164,7 @@ func (m *manager) GetEnvironments() ([]Environment, error) { return err } if specFileExists { - envName := filepath.Clean(strings.TrimPrefix(path, string(m.environmentsDir)+"/")) + envName := filepath.Clean(strings.TrimPrefix(path, string(m.environmentsPath)+"/")) specFile, err := afero.ReadFile(m.appFS, specPath) if err != nil { return err @@ -187,30 +189,112 @@ func (m *manager) GetEnvironments() ([]Environment, error) { return envs, nil } -func (m *manager) GenerateKsonnetLibData(spec ClusterSpec) ([]byte, []byte, error) { +func (m *manager) SetEnvironment(name string, desired Environment) error { + // Check whether this environment exists + envExists, err := m.environmentExists(name) + if err != nil { + return err + } + if !envExists { + return fmt.Errorf("Environment '%s' does not exist", name) + } + + if name == desired.Name { + return nil + } + + // ensure new environment name does not contain punctuation + if !isValidName(desired.Name) { + return fmt.Errorf("Environment '%s' is not valid; must not contain punctuation or trailing slashes", desired.Name) + } + + // If the name has changed, the directory location needs to be moved to + // reflect the change. + if len(desired.Name) != 0 { + // Ensure not overwriting another environment + desiredExists, err := m.environmentExists(desired.Name) + if err != nil { + return err + } + if desiredExists { + return fmt.Errorf("Can not update '%s' to '%s', it already exists", name, desired.Name) + } + + // Move the directory + pathOld := string(appendToAbsPath(m.environmentsPath, name)) + pathNew := string(appendToAbsPath(m.environmentsPath, desired.Name)) + err = m.appFS.Rename(pathOld, pathNew) + if err != nil { + return err + } + + name = desired.Name + } + + // Update fields in spec.json + if len(desired.URI) != 0 { + newSpec, err := generateSpecData(desired.URI) + if err != nil { + return err + } + + envPath := appendToAbsPath(m.environmentsPath, name) + specPath := appendToAbsPath(envPath, specFilename) + return afero.WriteFile(m.appFS, string(specPath), newSpec, os.ModePerm) + } + + return nil +} + +func (m *manager) generateKsonnetLibData(spec ClusterSpec) ([]byte, []byte, []byte, error) { // Get cluster specification data, possibly from the network. text, err := spec.data() if err != nil { - return nil, nil, err + return nil, nil, nil, err } - ksonnetLibDir := appendToAbsPath(m.environmentsDir, defaultEnvName) + ksonnetLibDir := appendToAbsPath(m.environmentsPath, defaultEnvName) // Deserialize the API object. s := kubespec.APISpec{} err = json.Unmarshal(text, &s) if err != nil { - return nil, nil, err + return nil, nil, nil, err } s.Text = text s.FilePath = filepath.Dir(string(ksonnetLibDir)) // Emit Jsonnet code. - return ksonnet.Emit(&s, nil, nil) + extensionsLibData, k8sLibData, err := ksonnet.Emit(&s, nil, nil) + return extensionsLibData, k8sLibData, text, err } func generateSpecData(uri string) ([]byte, error) { // Format the spec json and return; preface keys with 2 space idents. return json.MarshalIndent(EnvironmentSpec{URI: uri}, "", " ") } + +func (m *manager) environmentExists(name string) (bool, error) { + envs, err := m.GetEnvironments() + if err != nil { + return false, err + } + + envExists := false + for _, env := range envs { + if env.Name == name { + envExists = true + break + } + } + + return envExists, nil +} + +// regex matcher to ensure environment name does not contain punctuation +func isValidName(envName string) bool { + hasPunctuation := regexp.MustCompile(`[,;.':!()?"{}\[\]*&%@$]+`).MatchString + hasTrailingSlashes := regexp.MustCompile(`/+$`).MatchString + return !hasPunctuation(envName) && !hasTrailingSlashes(envName) +} diff --git a/metadata/environment_test.go b/metadata/environment_test.go index 532c2c8e..3d9c9fd3 100644 --- a/metadata/environment_test.go +++ b/metadata/environment_test.go @@ -16,9 +16,10 @@ package metadata import ( + "encoding/json" "fmt" "os" - "strings" + "strings" "testing" "github.com/spf13/afero" @@ -28,9 +29,9 @@ const ( mockSpecJSON = "spec.json" mockSpecJSONURI = "localhost:8080" - mockEnvName = "us-west/test" - mockEnvName2 = "us-west/prod" - mockEnvName3 = "us-east/test" + mockEnvName = "us-west/test" + mockEnvName2 = "us-west/prod" + mockEnvName3 = "us-east/test" ) func mockEnvironments(t *testing.T, appName string) *manager { @@ -45,14 +46,11 @@ func mockEnvironments(t *testing.T, appName string) *manager { t.Fatalf("Failed to init cluster spec: %v", err) } - envDir := appendToAbsPath(appPath, environmentsDir) - testDirExists(t, string(envDir)) - envNames := []string{defaultEnvName, mockEnvName, mockEnvName2, mockEnvName3} for _, env := range envNames { - envPath := appendToAbsPath(envDir, env) + envPath := appendToAbsPath(m.environmentsPath, env) - specPath := appendToAbsPath(envPath, mockSpecJSON) + specPath := appendToAbsPath(envPath, mockSpecJSON) specData, err := generateSpecData(mockSpecJSONURI) if err != nil { t.Fatalf("Expected to marshal:\n%s\n, but failed", mockSpecJSONURI) @@ -74,7 +72,7 @@ func testDirExists(t *testing.T, path string) { 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 testDirNotExists(t *testing.T, path string) { @@ -91,9 +89,9 @@ func TestDeleteEnvironment(t *testing.T) { m := mockEnvironments(t, appName) // Test that both directory and empty parent directory is deleted. - expectedPath := appendToAbsPath(m.environmentsDir, mockEnvName3) + expectedPath := appendToAbsPath(m.environmentsPath, mockEnvName3) parentDir := strings.Split(mockEnvName3, "/")[0] - expectedParentPath := appendToAbsPath(m.environmentsDir, parentDir) + expectedParentPath := appendToAbsPath(m.environmentsPath, parentDir) err := m.DeleteEnvironment(mockEnvName3) if err != nil { t.Fatalf("Expected %s to be deleted but got err:\n %s", mockEnvName3, err) @@ -102,9 +100,9 @@ func TestDeleteEnvironment(t *testing.T) { testDirNotExists(t, string(expectedParentPath)) // Test that only leaf directory is deleted if parent directory is shared - expectedPath = appendToAbsPath(m.environmentsDir, mockEnvName2) + expectedPath = appendToAbsPath(m.environmentsPath, mockEnvName2) parentDir = strings.Split(mockEnvName2, "/")[0] - expectedParentPath = appendToAbsPath(m.environmentsDir, parentDir) + expectedParentPath = appendToAbsPath(m.environmentsPath, parentDir) err = m.DeleteEnvironment(mockEnvName2) if err != nil { t.Fatalf("Expected %s to be deleted but got err:\n %s", mockEnvName3, err) @@ -129,3 +127,54 @@ func TestGetEnvironments(t *testing.T) { t.Fatalf("Expected env URI to be %s, got %s", mockSpecJSONURI, envs[0].URI) } } + +func TestSetEnvironment(t *testing.T) { + appName := "test-set-envs" + m := mockEnvironments(t, appName) + + setName := "new-env" + setURI := "http://example.com" + set := Environment{Name: setName, URI: setURI} + + // Test updating an environment that doesn't exist + err := m.SetEnvironment("notexists", set) + 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, Environment{Name: mockEnvName2}) + if err == nil { + t.Fatalf("Expected error when setting \"%s\" to \"%s\", because env already exists", mockEnvName, mockEnvName2) + } + + // Test changing the name and URI of a an existing environment. + // Ensure new env directory is created, and old directory no longer exists. + // Also ensure URI is set in spec.json + err = m.SetEnvironment(mockEnvName, set) + if err != nil { + t.Fatalf("Could not set \"%s\", got:\n %s", mockEnvName, err) + } + + envPath := appendToAbsPath(AbsPath(appName), environmentsDir) + expectedPathExists := appendToAbsPath(envPath, set.Name) + expectedPathNotExists := appendToAbsPath(envPath, mockEnvName) + + testDirExists(t, string(expectedPathExists)) + testDirNotExists(t, string(expectedPathNotExists)) + + expectedSpecPath := appendToAbsPath(expectedPathExists, specFilename) + specData, err := afero.ReadFile(testFS, string(expectedSpecPath)) + if err != nil { + t.Fatalf("Failed to read spec file:\n %s", err) + } + + var envSpec EnvironmentSpec + err = json.Unmarshal(specData, &envSpec) + if err != nil { + t.Fatalf("Failed to read spec file:\n %s", err) + } + if envSpec.URI != set.URI { + t.Fatalf("Expected set URI to be \"%s\", got:\n %s", set.URI, envSpec.URI) + } +} diff --git a/metadata/interface.go b/metadata/interface.go index d450bd03..6a928670 100644 --- a/metadata/interface.go +++ b/metadata/interface.go @@ -36,10 +36,10 @@ type Manager interface { Root() AbsPath ComponentPaths() (AbsPaths, error) LibPaths(envName string) (libPath, envLibPath AbsPath) - GenerateKsonnetLibData(spec ClusterSpec) ([]byte, []byte, error) - CreateEnvironment(name, uri string, spec ClusterSpec, extensionsLibData, k8sLibData []byte) error + CreateEnvironment(name, uri string, spec ClusterSpec) error DeleteEnvironment(name string) error GetEnvironments() ([]Environment, error) + SetEnvironment(name string, desired Environment) error // // TODO: Fill in methods as we need them. // diff --git a/metadata/manager.go b/metadata/manager.go index 987a37f8..9ba21b80 100644 --- a/metadata/manager.go +++ b/metadata/manager.go @@ -40,12 +40,12 @@ const ( type manager struct { appFS afero.Fs - rootPath AbsPath - ksonnetPath AbsPath - libPath AbsPath - componentsPath AbsPath - environmentsDir AbsPath - vendorDir AbsPath + rootPath AbsPath + ksonnetPath AbsPath + libPath AbsPath + componentsPath AbsPath + environmentsPath AbsPath + vendorDir AbsPath } func findManager(abs AbsPath, appFS afero.Fs) (*manager, error) { @@ -80,7 +80,7 @@ func initManager(rootPath AbsPath, spec ClusterSpec, appFS afero.Fs) (*manager, // either (e.g., GET'ing the spec from a live cluster returns 404) does not // result in a partially-initialized directory structure. // - extensionsLibData, k8sLibData, err := m.GenerateKsonnetLibData(spec) + extensionsLibData, k8sLibData, specData, err := m.generateKsonnetLibData(spec) if err != nil { return nil, err } @@ -92,7 +92,7 @@ func initManager(rootPath AbsPath, spec ClusterSpec, appFS afero.Fs) (*manager, // Initialize environment, and cache specification data. // TODO the URI for the default environment needs to be generated from KUBECONFIG - if err := m.CreateEnvironment(defaultEnvName, "", spec, extensionsLibData, k8sLibData); err != nil { + if err := m.createEnvironment(defaultEnvName, "", extensionsLibData, k8sLibData, specData); err != nil { return nil, err } @@ -103,12 +103,12 @@ func newManager(rootPath AbsPath, appFS afero.Fs) *manager { return &manager{ appFS: appFS, - rootPath: rootPath, - ksonnetPath: appendToAbsPath(rootPath, ksonnetDir), - libPath: appendToAbsPath(rootPath, libDir), - componentsPath: appendToAbsPath(rootPath, componentsDir), - environmentsDir: appendToAbsPath(rootPath, environmentsDir), - vendorDir: appendToAbsPath(rootPath, vendorDir), + rootPath: rootPath, + ksonnetPath: appendToAbsPath(rootPath, ksonnetDir), + libPath: appendToAbsPath(rootPath, libDir), + componentsPath: appendToAbsPath(rootPath, componentsDir), + environmentsPath: appendToAbsPath(rootPath, environmentsDir), + vendorDir: appendToAbsPath(rootPath, vendorDir), } } @@ -136,7 +136,7 @@ func (m *manager) ComponentPaths() (AbsPaths, error) { } func (m *manager) LibPaths(envName string) (libPath, envLibPath AbsPath) { - return m.libPath, appendToAbsPath(m.environmentsDir, envName) + return m.libPath, appendToAbsPath(m.environmentsPath, envName) } func (m *manager) createAppDirTree() error { @@ -152,6 +152,7 @@ func (m *manager) createAppDirTree() error { m.ksonnetPath, m.libPath, m.componentsPath, + m.environmentsPath, m.vendorDir, } diff --git a/pkg/kubecfg/env.go b/pkg/kubecfg/env.go index 47b4ac2f..d43592c7 100644 --- a/pkg/kubecfg/env.go +++ b/pkg/kubecfg/env.go @@ -16,6 +16,8 @@ package kubecfg import ( + "fmt" + "io" "sort" "strings" @@ -45,12 +47,7 @@ func (c *EnvAddCmd) Run() error { return err } - extensionsLibData, k8sLibData, err := manager.GenerateKsonnetLibData(c.spec) - if err != nil { - return err - } - - return manager.CreateEnvironment(c.name, c.uri, c.spec, extensionsLibData, k8sLibData) + return manager.CreateEnvironment(c.name, c.uri, c.spec) } // ================================================================== @@ -84,15 +81,15 @@ func NewEnvListCmd(rootPath metadata.AbsPath) (*EnvListCmd, error) { return &EnvListCmd{rootPath: rootPath}, nil } -func (c *EnvListCmd) Run() (string, error) { +func (c *EnvListCmd) Run(out io.Writer) error { manager, err := metadata.Find(c.rootPath) if err != nil { - return "", err + return err } envs, err := manager.GetEnvironments() if err != nil { - return "", err + return err } // Sort environments by ascending alphabetical name @@ -119,5 +116,33 @@ func (c *EnvListCmd) Run() (string, error) { lines = append(lines, env.Name+nameSpacing+env.URI+"\n") } - return strings.Join(lines, ""), nil + formattedEnvsList := strings.Join(lines, "") + + _, err = fmt.Fprint(out, formattedEnvsList) + return err +} + +// ================================================================== + +type EnvSetCmd struct { + name string + + desiredName string + desiredURI string + + rootPath metadata.AbsPath +} + +func NewEnvSetCmd(name, desiredName, desiredURI string, rootPath metadata.AbsPath) (*EnvSetCmd, error) { + return &EnvSetCmd{name: name, desiredName: desiredName, desiredURI: desiredURI, rootPath: rootPath}, nil +} + +func (c *EnvSetCmd) Run() error { + manager, err := metadata.Find(c.rootPath) + if err != nil { + return err + } + + desired := metadata.Environment{Name: c.desiredName, URI: c.desiredURI} + return manager.SetEnvironment(c.name, desired) } -- GitLab