From 19b3b9280f3acaa4013c809a18e0c46a64487974 Mon Sep 17 00:00:00 2001
From: Jessica Yuen <im.jessicayuen@gmail.com>
Date: Fri, 2 Feb 2018 13:18:55 -0800
Subject: [PATCH] Migrate environment spec.json to the app.yaml model

Currently spec.json contains detail about an environment's namespace and
server. Following the proposal at
design/proposals/modular-components.md, this change will consolidate
environment specifications in the common ksonnet app.yaml file.

An environment specification for the environment "dev", may look as
follows:

environments:
  dev:
    destinations:
    - namespace: foo
      server: example.com
    k8sVersion: "1.8.1"
    path: dev
    targets:
    - db

Note: This change currently doesn't support

(1) population of the k8sVersion field. This will occur as we migrate
the environment .metadata folder.
(2) deployment to more than one destination. This will occur once ks
supports multi-cluster deployment.
(3) setting of details other than the env name in `ks env set`. Prior
to this change, users are able to namespace and server URI, however it
becomes ambiguous which namespace is being set for an environment where
there can be multiple destinations. We will encourage configuration in
app.yaml itself.
(4) targets. This will come in a later change.

Signed-off-by: Jessica Yuen <im.jessicayuen@gmail.com>
---
 cmd/diff.go                             |   9 +-
 cmd/env.go                              |  21 +-
 cmd/root.go                             |  49 +++-
 docs/cli-reference/ks_env_set.md        |  17 +-
 integration/fixtures/sampleapp/app.yaml |  11 -
 integration/integration_suite_test.go   |  30 ++-
 metadata/app.go                         |  48 ++++
 metadata/app/schema.go                  |  39 ++-
 metadata/app/schema_test.go             |  67 ++++-
 metadata/environment.go                 | 327 +++++++++---------------
 metadata/environment_test.go            |  85 +++---
 metadata/interface.go                   |   9 +-
 metadata/manager.go                     |  32 +--
 metadata/manager_test.go                |   6 +-
 pkg/kubecfg/env.go                      |  39 +--
 testdata/testapp/app.yaml               |   7 +
 16 files changed, 425 insertions(+), 371 deletions(-)
 delete mode 100644 integration/fixtures/sampleapp/app.yaml
 create mode 100644 metadata/app.go

diff --git a/cmd/diff.go b/cmd/diff.go
index 2893f164..4cb362ac 100644
--- a/cmd/diff.go
+++ b/cmd/diff.go
@@ -323,7 +323,7 @@ func expandEnvObjs(fs afero.Fs, cmd *cobra.Command, env string, manager metadata
 	}
 
 	libPath, vendorPath := manager.LibPaths()
-	metadataPath, mainPath, paramsPath, specPath := manager.EnvPaths(env)
+	metadataPath, mainPath, paramsPath := manager.EnvPaths(env)
 	componentPaths, err := manager.ComponentPaths()
 	if err != nil {
 		return nil, err
@@ -334,10 +334,13 @@ func expandEnvObjs(fs afero.Fs, cmd *cobra.Command, env string, manager metadata
 		return nil, err
 	}
 	params := importParams(string(paramsPath))
-	spec := importEnv(string(specPath))
+	envSpec, err := importEnv(manager, env)
+	if err != nil {
+		return nil, err
+	}
 
 	expander.FlagJpath = append([]string{string(libPath), string(vendorPath), string(metadataPath)}, expander.FlagJpath...)
-	expander.ExtCodes = append([]string{baseObj, params, spec}, expander.ExtCodes...)
+	expander.ExtCodes = append([]string{baseObj, params, envSpec}, expander.ExtCodes...)
 
 	envFiles := []string{string(mainPath)}
 
diff --git a/cmd/env.go b/cmd/env.go
index 8be4cf46..638b7edb 100644
--- a/cmd/env.go
+++ b/cmd/env.go
@@ -313,9 +313,7 @@ var envSetCmd = &cobra.Command{
 			return err
 		}
 
-		server, namespace, err := resolveEnvFlags(flags)
-
-		c, err := kubecfg.NewEnvSetCmd(originalName, name, server, namespace, manager)
+		c, err := kubecfg.NewEnvSetCmd(originalName, name, manager)
 		if err != nil {
 			return err
 		}
@@ -324,8 +322,7 @@ var envSetCmd = &cobra.Command{
 	},
 	Long: `
 The ` + "`set`" + ` command lets you change the fields of an existing environment.
-You can update any of your environment's (1) name (2) namespace and
-(3) server (cluster URI).
+You can currently only update your environment's name.
 
 Note that changing the name of an environment will also update the corresponding
 directory structure in ` + "`environments/`" + `.
@@ -336,19 +333,9 @@ directory structure in ` + "`environments/`" + `.
 
 ### Syntax
 `,
-	Example: `# Update the API server address of the environment 'us-west/staging'.
-ks env set us-west/staging --server=http://example.com
-
-# Update the namespace of the environment 'us-west/staging'.
-ks env set us-west/staging --namespace=staging
-
-# Update both the name and the server of the environment 'us-west/staging'.
+	Example: `#Update the name of the environment 'us-west/staging'.
 # Updating the name will update the directory structure in 'environments/'.
-ks env set us-west/staging --server=http://example.com --name=us-east/staging
-
-# Update the API server address of the environment 'us-west/staging' based on the
-# server in the 'staging-west' context of your kubeconfig file.
-ks env set us-west/staging --context=staging-west`,
+ks env set us-west/staging --name=us-east/staging`,
 }
 
 func commonEnvFlags(flags *pflag.FlagSet) (server, namespace, context string, err error) {
diff --git a/cmd/root.go b/cmd/root.go
index 793dde9b..bb5668f7 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -365,7 +365,8 @@ func overrideCluster(envName string, clientConfig clientcmd.ClientConfig, overri
 		return err
 	}
 
-	server, err := utils.NormalizeURL(env.Server)
+	// TODO support multi-cluster deployment.
+	server, err := utils.NormalizeURL(env.Destinations[0].Server)
 	if err != nil {
 		return err
 	}
@@ -377,13 +378,13 @@ func overrideCluster(envName string, clientConfig clientcmd.ClientConfig, overri
 			overrides.Context.Cluster = clusterName
 		}
 		if overrides.Context.Namespace == "" {
-			log.Debugf("Overwriting --namespace flag with '%s'", env.Namespace)
-			overrides.Context.Namespace = env.Namespace
+			log.Debugf("Overwriting --namespace flag with '%s'", env.Destinations[0].Namespace)
+			overrides.Context.Namespace = env.Destinations[0].Namespace
 		}
 		return nil
 	}
 
-	return fmt.Errorf("Attempting to deploy to environment '%s' at '%s', but cannot locate a server at that address", envName, env.Server)
+	return fmt.Errorf("Attempting to deploy to environment '%s' at '%s', but cannot locate a server at that address", envName, env.Destinations[0].Server)
 }
 
 type cmdObjExpanderConfig struct {
@@ -432,7 +433,7 @@ func (te *cmdObjExpander) Expand() ([]*unstructured.Unstructured, error) {
 	}
 
 	libPath, vendorPath := manager.LibPaths()
-	metadataPath, mainPath, paramsPath, specPath := manager.EnvPaths(te.config.env)
+	metadataPath, mainPath, paramsPath := manager.EnvPaths(te.config.env)
 
 	expander.FlagJpath = append([]string{string(libPath), string(vendorPath), string(metadataPath)}, expander.FlagJpath...)
 
@@ -451,8 +452,12 @@ func (te *cmdObjExpander) Expand() ([]*unstructured.Unstructured, error) {
 	//
 
 	params := importParams(string(paramsPath))
-	spec := importEnv(string(specPath))
-	expander.ExtCodes = append([]string{baseObj, params, spec}, expander.ExtCodes...)
+	envSpec, err := importEnv(manager, te.config.env)
+	if err != nil {
+		return nil, err
+	}
+
+	expander.ExtCodes = append([]string{baseObj, params, envSpec}, expander.ExtCodes...)
 
 	//
 	// Expand the ksonnet app as rendered for environment `env`.
@@ -543,6 +548,32 @@ func importParams(path string) string {
 	return fmt.Sprintf(`%s=import "%s"`, metadata.ParamsExtCodeKey, path)
 }
 
-func importEnv(path string) string {
-	return fmt.Sprintf(`%s=import "%s"`, metadata.EnvExtCodeKey, path)
+func importEnv(manager metadata.Manager, env string) (string, error) {
+	app, err := manager.AppSpec()
+	if err != nil {
+		return "", err
+	}
+
+	spec, exists := app.GetEnvironmentSpec(env)
+	if !exists {
+		return "", fmt.Errorf("Environment '%s' does not exist in app.yaml", env)
+	}
+
+	// TODO pass namespace and server as params when ks supports multi-cluster deployment
+	type EnvironmentSpec struct {
+		Server    string `json:"server"`
+		Namespace string `json:"namespace"`
+	}
+
+	toMarshal := &EnvironmentSpec{
+		Server:    spec.Destinations[0].Server,
+		Namespace: spec.Destinations[0].Namespace,
+	}
+
+	marshalled, err := json.Marshal(toMarshal)
+	if err != nil {
+		return "", err
+	}
+
+	return fmt.Sprintf(`%s=%s`, metadata.EnvExtCodeKey, string(marshalled)), nil
 }
diff --git a/docs/cli-reference/ks_env_set.md b/docs/cli-reference/ks_env_set.md
index d5d00aa2..3fdece1f 100644
--- a/docs/cli-reference/ks_env_set.md
+++ b/docs/cli-reference/ks_env_set.md
@@ -7,8 +7,7 @@ Set environment-specific fields (name, namespace, server)
 
 
 The `set` command lets you change the fields of an existing environment.
-You can update any of your environment's (1) name (2) namespace and
-(3) server (cluster URI).
+You can currently only update your environment's name.
 
 Note that changing the name of an environment will also update the corresponding
 directory structure in `environments/`.
@@ -27,19 +26,9 @@ ks env set <env-name>
 ### Examples
 
 ```
-# Update the API server address of the environment 'us-west/staging'.
-ks env set us-west/staging --server=http://example.com
-
-# Update the namespace of the environment 'us-west/staging'.
-ks env set us-west/staging --namespace=staging
-
-# Update both the name and the server of the environment 'us-west/staging'.
+#Update the name of the environment 'us-west/staging'.
 # Updating the name will update the directory structure in 'environments/'.
-ks env set us-west/staging --server=http://example.com --name=us-east/staging
-
-# Update the API server address of the environment 'us-west/staging' based on the
-# server in the 'staging-west' context of your kubeconfig file.
-ks env set us-west/staging --context=staging-west
+ks env set us-west/staging --name=us-east/staging
 ```
 
 ### Options
diff --git a/integration/fixtures/sampleapp/app.yaml b/integration/fixtures/sampleapp/app.yaml
deleted file mode 100644
index 8e470536..00000000
--- a/integration/fixtures/sampleapp/app.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-apiVersion: 0.0.1
-kind: ksonnet.io/app
-name: sampleapp
-registries:
-  incubator:
-    gitVersion:
-      commitSha: 9d78d6bb445d530d5b927656d2293d4f12654608
-      refSpec: master
-    protocol: github
-    uri: github.com/ksonnet/parts/tree/master/incubator
-version: 0.0.1
diff --git a/integration/integration_suite_test.go b/integration/integration_suite_test.go
index ab3112b3..76998ead 100644
--- a/integration/integration_suite_test.go
+++ b/integration/integration_suite_test.go
@@ -3,7 +3,7 @@
 package integration
 
 import (
-	"encoding/json"
+	"github.com/ksonnet/ksonnet/metadata/app"
 	"flag"
 	"fmt"
 	"io"
@@ -89,25 +89,35 @@ func containsString(haystack []string, needle string) bool {
 }
 
 func runKsonnetWith(flags []string, host, ns string) error {
-	type envSpec struct {
-		Server    string `json:"server"`
-		Namespace string `json:"namespace"`
+	spec := app.Spec{
+		Version: "0.0.1",
+		APIVersion: "0.0.1",
+		Environments: app.EnvironmentSpecs{
+			"default": &app.EnvironmentSpec{
+				Destinations: app.EnvironmentDestinationSpecs{
+					&app.EnvironmentDestinationSpec{
+						Namespace: ns,
+						Server:    host,
+					},
+				},
+			},
+		},
 	}
 
-	defaultEnvSpecPath := path.Join(*ksonnetData, "sampleapp/environments/default/spec.json")
-	defaultEnvSpecFile, err := os.Create(defaultEnvSpecPath)
+	appSpecPath := path.Join(*ksonnetData, "sampleapp/app.yaml")
+	specFile, err := os.Create(appSpecPath)
 	if err != nil {
 		return err
 	}
-	data, err := json.MarshalIndent(envSpec{Server: host, Namespace: ns}, "", "  ")
+	data, err := spec.Marshal()
 	if err != nil {
 		return err
 	}
-	defaultEnvSpecFile.Write(data)
-	if err := defaultEnvSpecFile.Close(); err != nil {
+	specFile.Write(data)
+	if err := specFile.Close(); err != nil {
 		return err
 	}
-	defer os.Remove(defaultEnvSpecPath)
+	defer os.Remove(appSpecPath)
 
 	args := []string{}
 	if *kubeconfig != "" && !containsString(flags, "--kubeconfig") {
diff --git a/metadata/app.go b/metadata/app.go
new file mode 100644
index 00000000..f8614de0
--- /dev/null
+++ b/metadata/app.go
@@ -0,0 +1,48 @@
+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/schema.go b/metadata/app/schema.go
index 1db429f8..7cd72781 100644
--- a/metadata/app/schema.go
+++ b/metadata/app/schema.go
@@ -36,6 +36,8 @@ var (
 	ErrEnvironmentNameInvalid = fmt.Errorf("Environment name is invalid")
 	// ErrEnvironmentExists is the error when trying to create an environment that already exists.
 	ErrEnvironmentExists = fmt.Errorf("Environment with name already exists")
+	// ErrEnvironmentNotExists is the error when trying to update an environment that doesn't exist.
+	ErrEnvironmentNotExists = fmt.Errorf("Environment with name doesn't exist")
 )
 
 type Spec struct {
@@ -75,6 +77,8 @@ type EnvironmentSpecs map[string]*EnvironmentSpec
 // EnvironmentSpec contains the specification for ksonnet environments.
 //
 // KubernetesVersion: The Kubernetes version the target cluster is running on.
+// Path:              The relative path containing metadata for this environment,
+//                    rooted at the directory 'environments'.
 // Destinations:      One or more cluster addresses that this environment
 //                    points to.
 // Targets:           The relative component paths that this environment wishes
@@ -82,8 +86,9 @@ type EnvironmentSpecs map[string]*EnvironmentSpec
 type EnvironmentSpec struct {
 	Name              string                      `json:"-"`
 	KubernetesVersion string                      `json:"k8sVersion"`
+	Path              string                      `json:"path"`
 	Destinations      EnvironmentDestinationSpecs `json:"destinations"`
-	Targets           []string                    `json:"targets"`
+	Targets           []string                    `json:"targets,omitempty"`
 }
 
 // EnvironmentDestinationSpecs contains one or more EnvironmentDestinationSpec.
@@ -177,6 +182,16 @@ func (s *Spec) validate() error {
 	return nil
 }
 
+// GetEnvironmentSpecs returns all environment specifications.
+// We need to pre-populate th EnvironmentSpec name before returning.
+func (s *Spec) GetEnvironmentSpecs() EnvironmentSpecs {
+	for k, v := range s.Environments {
+		v.Name = k
+	}
+
+	return s.Environments
+}
+
 // GetEnvironmentSpec returns the environment specification for the environment.
 func (s *Spec) GetEnvironmentSpec(name string) (*EnvironmentSpec, bool) {
 	environmentSpec, ok := s.Environments[name]
@@ -207,3 +222,25 @@ func (s *Spec) DeleteEnvironmentSpec(name string) error {
 	delete(s.Environments, name)
 	return nil
 }
+
+// UpdateEnvironmentSpec updates the environment with the provided name to the
+// specified spec.
+func (s *Spec) UpdateEnvironmentSpec(name string, spec *EnvironmentSpec) error {
+	if spec.Name == "" {
+		return ErrEnvironmentNameInvalid
+	}
+
+	_, environmentSpecExists := s.Environments[name]
+	if !environmentSpecExists {
+		return ErrEnvironmentNotExists
+	}
+
+	if name != spec.Name {
+		if err := s.DeleteEnvironmentSpec(name); err != nil {
+			return err
+		}
+	}
+
+	s.Environments[spec.Name] = spec
+	return nil
+}
diff --git a/metadata/app/schema_test.go b/metadata/app/schema_test.go
index b0036777..980dc03c 100644
--- a/metadata/app/schema_test.go
+++ b/metadata/app/schema_test.go
@@ -171,6 +171,31 @@ func TestAddRegistryRefFailure(t *testing.T) {
 	}
 }
 
+func TestGetEnvironmentSpecs(t *testing.T) {
+	example1 := Spec{
+		Environments: EnvironmentSpecs{
+			"dev": &EnvironmentSpec{
+				Destinations: EnvironmentDestinationSpecs{
+					&EnvironmentDestinationSpec{
+						Namespace: "default",
+						Server:    "http://example.com",
+					},
+				},
+				KubernetesVersion: "1.8.0",
+			},
+		},
+	}
+
+	r1 := example1.GetEnvironmentSpecs()
+	if len(r1) != 1 {
+		t.Error("Expected environments to contain to be of length 1")
+	}
+
+	if r1["dev"].Name != "dev" {
+		t.Error("Expected to populate name value")
+	}
+}
+
 func TestGetEnvironmentSpecSuccess(t *testing.T) {
 	const (
 		env        = "dev"
@@ -221,7 +246,7 @@ func TestGetEnvironmentSpecFailure(t *testing.T) {
 
 	r1, ok := example1.GetEnvironmentSpec("prod")
 	if r1 != nil || ok {
-		t.Error("Expected environemnts to not contain 'prod'")
+		t.Error("Expected environments to not contain 'prod'")
 	}
 }
 
@@ -307,3 +332,43 @@ func TestDeleteEnvironmentSpec(t *testing.T) {
 		t.Error("Expected environment 'dev' to be deleted from spec, but still exists")
 	}
 }
+
+func TestUpdateEnvironmentSpec(t *testing.T) {
+	example1 := Spec{
+		Environments: EnvironmentSpecs{
+			"dev": &EnvironmentSpec{
+				Destinations: EnvironmentDestinationSpecs{
+					&EnvironmentDestinationSpec{
+						Namespace: "default",
+						Server:    "http://example.com",
+					},
+				},
+				KubernetesVersion: "1.8.0",
+			},
+		},
+	}
+
+	example2 := EnvironmentSpec{
+		Name: "foo",
+		Destinations: EnvironmentDestinationSpecs{
+			&EnvironmentDestinationSpec{
+				Namespace: "foo",
+				Server:    "http://example.com",
+			},
+		},
+		KubernetesVersion: "1.8.0",
+	}
+
+	err := example1.UpdateEnvironmentSpec("dev", &example2)
+	if err != nil {
+		t.Error("Expected to successfully update an environment in spec")
+	}
+
+	if _, ok := example1.GetEnvironmentSpec("dev"); ok {
+		t.Error("Expected environment 'dev' to be deleted from spec, but still exists")
+	}
+
+	if _, ok := example1.GetEnvironmentSpec("foo"); !ok {
+		t.Error("Expected environment 'foo' to be created in spec, but does not exists")
+	}
+}
diff --git a/metadata/environment.go b/metadata/environment.go
index 042a90a1..e5474b32 100644
--- a/metadata/environment.go
+++ b/metadata/environment.go
@@ -17,19 +17,17 @@ package metadata
 
 import (
 	"bytes"
-	"encoding/json"
 	"fmt"
-	"os"
 	"path"
 	"path/filepath"
-	"strings"
+
+	"github.com/ksonnet/ksonnet/metadata/app"
 
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/afero"
 
 	"github.com/ksonnet/ksonnet/generator"
 	param "github.com/ksonnet/ksonnet/metadata/params"
-	"github.com/ksonnet/ksonnet/utils"
 )
 
 const (
@@ -58,20 +56,6 @@ var envPaths = []string{
 	specFilename,
 }
 
-// Environment represents all fields of a ksonnet environment
-type Environment struct {
-	Path      string
-	Name      string
-	Server    string
-	Namespace string
-}
-
-// EnvironmentSpec represents the contents in spec.json.
-type EnvironmentSpec struct {
-	Server    string `json:"server"`
-	Namespace string `json:"namespace"`
-}
-
 func (m *manager) CreateEnvironment(name, server, namespace string, spec ClusterSpec) error {
 	b, err := spec.OpenAPI()
 	if err != nil {
@@ -84,20 +68,16 @@ func (m *manager) CreateEnvironment(name, server, namespace string, spec Cluster
 		return err
 	}
 
-	err = m.createEnvironment(name, server, namespace, kl.K, kl.K8s, kl.Swagger)
-	if err == nil {
-		log.Infof("Environment '%s' pointing to namespace '%s' and server address at '%s' successfully created", name, namespace, server)
-	}
-	return err
+	return m.createEnvironment(name, server, namespace, kl.K, kl.K8s, kl.Swagger)
 }
 
 func (m *manager) createEnvironment(name, server, namespace string, extensionsLibData, k8sLibData, specData []byte) error {
-	exists, err := m.environmentExists(name)
+	appSpec, err := m.AppSpec()
 	if err != nil {
-		log.Debug("Failed to check whether environment exists")
 		return err
 	}
-	if exists {
+
+	if _, exists := appSpec.GetEnvironmentSpec(name); exists {
 		return fmt.Errorf("Environment '%s' already exists", name)
 	}
 
@@ -106,6 +86,10 @@ func (m *manager) createEnvironment(name, server, namespace string, extensionsLi
 		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 := appendToAbsPath(m.environmentsPath, name)
@@ -122,12 +106,6 @@ func (m *manager) createEnvironment(name, server, namespace string, extensionsLi
 
 	log.Infof("Generating environment metadata at path '%s'", envPath)
 
-	// Generate the environment spec file.
-	envSpecData, err := generateSpecData(server, namespace)
-	if err != nil {
-		return err
-	}
-
 	metadata := []struct {
 		path AbsPath
 		data []byte
@@ -157,11 +135,6 @@ func (m *manager) createEnvironment(name, server, namespace string, extensionsLi
 			appendToAbsPath(envPath, paramsFileName),
 			m.generateParamsData(),
 		},
-		{
-			// spec file
-			appendToAbsPath(envPath, specFilename),
-			envSpecData,
-		},
 	}
 
 	for _, a := range metadata {
@@ -173,26 +146,43 @@ func (m *manager) createEnvironment(name, server, namespace string, extensionsLi
 		}
 	}
 
-	return nil
+	// update app.yaml
+	err = appSpec.AddEnvironmentSpec(&app.EnvironmentSpec{
+		Name: name,
+		Path: name,
+		Destinations: app.EnvironmentDestinationSpecs{
+			&app.EnvironmentDestinationSpec{
+				Server:    server,
+				Namespace: namespace,
+			},
+		},
+		// TODO specify k8s version once metadata is moved.
+	})
+
+	if err != nil {
+		return err
+	}
+
+	return m.WriteAppSpec(appSpec)
 }
 
 func (m *manager) DeleteEnvironment(name string) error {
-	envPath := string(appendToAbsPath(m.environmentsPath, name))
-
-	// Check whether this environment exists
-	envExists, err := m.environmentExists(name)
+	app, err := m.AppSpec()
 	if err != nil {
-		log.Debug("Failed to check whether environment exists")
 		return err
 	}
-	if !envExists {
+
+	env, ok := app.GetEnvironmentSpec(name)
+	if !ok {
 		return fmt.Errorf("Environment '%s' does not exist", name)
 	}
 
-	log.Infof("Deleting environment '%s' at path '%s'", name, envPath)
+	envPath := appendToAbsPath(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)
+	err = m.appFS.RemoveAll(string(envPath))
 	if err != nil {
 		log.Debugf("Failed to remove environment directory at path '%s'", envPath)
 		return err
@@ -205,184 +195,136 @@ func (m *manager) DeleteEnvironment(name string) error {
 		return err
 	}
 
+	// Update app spec.
+	if err := m.WriteAppSpec(app); err != nil {
+		return err
+	}
+
 	log.Infof("Successfully removed environment '%s'", name)
 	return nil
 }
 
-func (m *manager) GetEnvironments() ([]*Environment, error) {
-	envs := []*Environment{}
+func (m *manager) GetEnvironments() (app.EnvironmentSpecs, error) {
+	app, err := m.AppSpec()
+	if err != nil {
+		return nil, err
+	}
 
 	log.Debug("Retrieving all environments")
-	err := afero.Walk(m.appFS, string(m.environmentsPath), func(path string, f os.FileInfo, err error) error {
-		if err != nil {
-			log.Debugf("Failed to walk the path at '%s'", path)
-			return err
-		}
-		isDir, err := afero.IsDir(m.appFS, path)
-		if err != nil {
-			log.Debugf("Failed to check whether the path at '%s' is a directory", path)
-			return err
-		}
+	return app.GetEnvironmentSpecs(), nil
+}
 
-		if isDir {
-			// Only want leaf directories containing a spec.json
-			specPath := filepath.Join(path, specFilename)
-			specFileExists, err := afero.Exists(m.appFS, specPath)
-			if err != nil {
-				log.Debugf("Failed to check whether spec file at '$s' exists", specPath)
-				return err
-			}
-			if specFileExists {
-				envName := filepath.Clean(strings.TrimPrefix(path, string(m.environmentsPath)+"/"))
-				specFile, err := afero.ReadFile(m.appFS, specPath)
-				if err != nil {
-					log.Debugf("Failed to read spec file at path '%s'", specPath)
-					return err
-				}
-				var envSpec EnvironmentSpec
-				err = json.Unmarshal(specFile, &envSpec)
-				if err != nil {
-					log.Debugf("Failed to convert the spec file at path '%s' to JSON", specPath)
-					return err
-				}
-
-				log.Debugf("Found environment '%s', with server '%s' and namespace '%s'", envName, envSpec.Server, envSpec.Namespace)
-				envs = append(envs, &Environment{Name: envName, Path: path, Server: envSpec.Server, Namespace: envSpec.Namespace})
-			}
-		}
+func (m *manager) GetEnvironment(name string) (*app.EnvironmentSpec, error) {
+	app, err := m.AppSpec()
+	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
+}
+
+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)
 	if err != nil {
-		return nil, err
+		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)
 	}
 
-	return envs, nil
-}
+	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.
+	//
 
-func (m *manager) GetEnvironment(name string) (*Environment, error) {
-	envs, err := m.GetEnvironments()
+	appSpec, err := m.AppSpec()
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	for _, env := range envs {
-		if env.Name == name {
-			return env, nil
-		}
+	current, exists := appSpec.GetEnvironmentSpec(name)
+	if !exists {
+		return fmt.Errorf("Trying to update an environment that doesn't exist")
 	}
 
-	return nil, fmt.Errorf("Environment '%s' does not exist", name)
-}
+	err = appSpec.UpdateEnvironmentSpec(name, &app.EnvironmentSpec{
+		Name:              desiredName,
+		Destinations:      current.Destinations,
+		KubernetesVersion: current.KubernetesVersion,
+		Targets:           current.Targets,
+		Path:              desiredName,
+	})
 
-func (m *manager) SetEnvironment(name string, desired *Environment) error {
-	env, err := m.GetEnvironment(name)
 	if err != nil {
 		return err
 	}
 
+	//
 	// If the name has changed, the directory location needs to be moved to
 	// reflect the change.
-	if name != desired.Name && len(desired.Name) != 0 {
-		// ensure new environment name does not contain punctuation
-		if !isValidName(desired.Name) {
-			return fmt.Errorf("Environment name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name)
-		}
+	//
 
-		log.Infof("Setting environment name from '%s' to '%s'", name, desired.Name)
+	pathOld := appendToAbsPath(m.environmentsPath, name)
+	pathNew := appendToAbsPath(m.environmentsPath, desiredName)
+	exists, err = afero.DirExists(m.appFS, string(pathNew))
+	if err != nil {
+		return err
+	}
 
-		// Ensure not overwriting another environment
-		desiredExists, err := m.environmentExists(desired.Name)
+	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(string(pathNew), string(pathOld)) {
+		// the new directory is a child of the old directory --
+		// rename won't work.
+		err = m.appFS.MkdirAll(string(pathNew), defaultFolderPermissions)
 		if err != nil {
-			log.Debugf("Failed to check whether environment '%s' already exists", desired.Name)
 			return err
 		}
-		if desiredExists {
-			return fmt.Errorf("Can not update '%s' to '%s', it already exists", name, desired.Name)
-		}
-
-		//
-		// Move the directory
-		//
-
-		pathOld := appendToAbsPath(m.environmentsPath, name)
-		pathNew := appendToAbsPath(m.environmentsPath, desired.Name)
-		exists, err := afero.DirExists(m.appFS, string(pathNew))
+		m.tryMvEnvDir(pathOld, pathNew)
+	} else {
+		// Need to first create subdirectories that don't exist
+		intermediatePath := path.Dir(string(pathNew))
+		log.Debugf("Moving directory at path '%s' to '%s'", string(pathOld), string(pathNew))
+		err = m.appFS.MkdirAll(intermediatePath, defaultFolderPermissions)
 		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(string(pathNew), string(pathOld)) {
-			// the new directory is a child of the old directory --
-			// rename won't work.
-			err = m.appFS.MkdirAll(string(pathNew), defaultFolderPermissions)
-			if err != nil {
-				return err
-			}
-			m.tryMvEnvDir(pathOld, pathNew)
-		} else {
-			// Need to first create subdirectories that don't exist
-			intermediatePath := path.Dir(string(pathNew))
-			log.Debugf("Moving directory at path '%s' to '%s'", string(pathOld), string(pathNew))
-			err = m.appFS.MkdirAll(intermediatePath, defaultFolderPermissions)
-			if err != nil {
-				return err
-			}
-			// finally, move the directory
-			err = m.appFS.Rename(string(pathOld), string(pathNew))
-			if err != nil {
-				log.Debugf("Failed to move path '%s' to '%s", string(pathOld), string(pathNew))
-				return err
-			}
-		}
-
-		// clean up any empty parent directory paths
-		err = m.cleanEmptyParentDirs(name)
+		// finally, move the directory
+		err = m.appFS.Rename(string(pathOld), string(pathNew))
 		if err != nil {
+			log.Debugf("Failed to move path '%s' to '%s", string(pathOld), string(pathNew))
 			return err
 		}
-		name = desired.Name
 	}
 
-	//
-	// Update fields in spec.json.
-	//
-
-	var server string
-	if len(desired.Server) != 0 {
-		log.Infof("Setting environment server to '%s'", desired.Server)
-		server = desired.Server
-	} else {
-		server = env.Server
-	}
-	var namespace string
-	if len(desired.Namespace) != 0 {
-		log.Infof("Setting environment namespace to '%s'", desired.Namespace)
-		namespace = desired.Namespace
-	} else {
-		namespace = env.Namespace
-	}
-
-	newSpec, err := generateSpecData(server, namespace)
+	// clean up any empty parent directory paths
+	err = m.cleanEmptyParentDirs(name)
 	if err != nil {
-		log.Debugf("Failed to generate %s with server '%s' and namespace '%s'", specFilename, server, namespace)
 		return err
 	}
 
-	envPath := appendToAbsPath(m.environmentsPath, name)
-	specPath := appendToAbsPath(envPath, specFilename)
-
-	err = afero.WriteFile(m.appFS, string(specPath), newSpec, defaultFilePermissions)
-	if err != nil {
-		log.Debugf("Failed to write %s at path '%s'", specFilename, specPath)
-		return err
-	}
+	m.WriteAppSpec(appSpec)
 
 	log.Infof("Successfully updated environment '%s'", name)
 	return nil
@@ -533,31 +475,14 @@ params + {
 `)
 }
 
-func generateSpecData(server, namespace string) ([]byte, error) {
-	server, err := utils.NormalizeURL(server)
-	if err != nil {
-		return nil, err
-	}
-
-	// Format the spec json and return; preface keys with 2 space idents.
-	return json.MarshalIndent(EnvironmentSpec{Server: server, Namespace: namespace}, "", "  ")
-}
-
 func (m *manager) environmentExists(name string) (bool, error) {
-	envs, err := m.GetEnvironments()
+	appSpec, err := m.AppSpec()
 	if err != nil {
 		return false, err
 	}
 
-	envExists := false
-	for _, env := range envs {
-		if env.Name == name {
-			envExists = true
-			break
-		}
-	}
-
-	return envExists, nil
+	_, ok := appSpec.GetEnvironmentSpec(name)
+	return ok, nil
 }
 
 func mergeParamMaps(base, overrides map[string]param.Params) map[string]param.Params {
diff --git a/metadata/environment_test.go b/metadata/environment_test.go
index 8ff6b67a..beea033a 100644
--- a/metadata/environment_test.go
+++ b/metadata/environment_test.go
@@ -16,27 +16,28 @@
 package metadata
 
 import (
-	"encoding/json"
 	"fmt"
 	"reflect"
 	"strings"
 	"testing"
 
+	"github.com/ksonnet/ksonnet/metadata/app"
+
 	param "github.com/ksonnet/ksonnet/metadata/params"
 	"github.com/spf13/afero"
 )
 
 const (
-	mockSpecJSONServer = "localhost:8080"
-
 	mockEnvName  = "us-west/test"
 	mockEnvName2 = "us-west/prod"
 	mockEnvName3 = "us-east/test"
 )
 
-var mockAPIServer = "http://example.com"
-var mockNamespace = "some-namespace"
-var mockEnvs = []string{defaultEnvName, mockEnvName, mockEnvName2, mockEnvName3}
+var (
+	mockAPIServer = "http://example.com"
+	mockNamespace = "some-namespace"
+	mockEnvs      = []string{defaultEnvName, mockEnvName, mockEnvName2, mockEnvName3}
+)
 
 func mockEnvironments(t *testing.T, appName string) *manager {
 	return mockEnvironmentsWith(t, appName, mockEnvs)
@@ -68,17 +69,6 @@ func mockEnvironmentsWith(t *testing.T, appName string, envNames []string) *mana
 		}
 		testFileExists(t, string(envFilePath))
 
-		specPath := appendToAbsPath(envPath, specFilename)
-		specData, err := generateSpecData(mockSpecJSONServer, mockNamespace)
-		if err != nil {
-			t.Fatalf("Expected to marshal:\nserver: %s\nnamespace: %s\n, but failed", mockSpecJSONServer, mockNamespace)
-		}
-		err = afero.WriteFile(testFS, string(specPath), specData, defaultFilePermissions)
-		if err != nil {
-			t.Fatalf("Could not write file at path: %s", specPath)
-		}
-		testFileExists(t, string(specPath))
-
 		paramsPath := appendToAbsPath(envPath, paramsFileName)
 		paramsData := m.generateParamsData()
 		err = afero.WriteFile(testFS, string(paramsPath), paramsData, defaultFilePermissions)
@@ -86,6 +76,22 @@ func mockEnvironmentsWith(t *testing.T, appName string, envNames []string) *mana
 			t.Fatalf("Could not write file at path: %s", paramsPath)
 		}
 		testFileExists(t, string(paramsPath))
+
+		appSpec, err := m.AppSpec()
+		if err != nil {
+			t.Fatal("Could not retrieve app spec")
+		}
+		appSpec.AddEnvironmentSpec(&app.EnvironmentSpec{
+			Name: env,
+			Path: env,
+			Destinations: app.EnvironmentDestinationSpecs{
+				&app.EnvironmentDestinationSpec{
+					Server:    mockAPIServer,
+					Namespace: mockNamespace,
+				},
+			},
+		})
+		m.WriteAppSpec(appSpec)
 	}
 
 	return m
@@ -157,8 +163,14 @@ func TestGetEnvironments(t *testing.T) {
 		t.Fatalf("Expected to get %d environments, got %d", 4, len(envs))
 	}
 
-	if envs[0].Server != mockSpecJSONServer {
-		t.Fatalf("Expected env server to be %s, got %s", mockSpecJSONServer, envs[0].Server)
+	name := envs[mockEnvName].Name
+	if name != mockEnvName {
+		t.Fatalf("Expected env name to be '%s', got '%s'", mockEnvName, name)
+	}
+
+	server := envs[mockEnvName].Destinations[0].Server
+	if server != mockAPIServer {
+		t.Fatalf("Expected env server to be %s, got %s", mockAPIServer, server)
 	}
 }
 
@@ -167,34 +179,28 @@ func TestSetEnvironment(t *testing.T) {
 	m := mockEnvironments(t, appName)
 
 	setName := "new-env"
-	setServer := "http://example.com"
-	setNamespace := "some-namespace"
-	set := Environment{Name: setName, Server: setServer, Namespace: setNamespace}
 
 	// Test updating an environment that doesn't exist
-	err := m.SetEnvironment("notexists", &set)
+	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, &Environment{Name: mockEnvName2})
+	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 and server of a an existing environment.
-	//
-
-	err = m.SetEnvironment(mockEnvName, &set)
+	// 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 := appendToAbsPath(AbsPath(appName), environmentsDir)
-	expectedPathExists := appendToAbsPath(envPath, set.Name)
+	expectedPathExists := appendToAbsPath(envPath, setName)
 	expectedPathNotExists := appendToAbsPath(envPath, mockEnvName)
 	testDirExists(t, string(expectedPathExists))
 	testDirNotExists(t, string(expectedPathNotExists))
@@ -214,23 +220,6 @@ func TestSetEnvironment(t *testing.T) {
 	// 	testFileExists(t, string(expectedFilePath))
 	// }
 
-	// ensure spec file contains the correct content
-	specData, err := afero.ReadFile(testFS, string(appendToAbsPath(expectedPathExists, specFilename)))
-	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.Server != set.Server {
-		t.Fatalf("Expected server to be set to '%s', got: '%s'", set.Server, envSpec.Server)
-	}
-	if envSpec.Namespace != set.Namespace {
-		t.Fatalf("Expected namespace to be set to '%s', got: '%s'", set.Namespace, envSpec.Namespace)
-	}
-
 	tests := []struct {
 		appName string
 		nameOld string
@@ -252,7 +241,7 @@ func TestSetEnvironment(t *testing.T) {
 
 	for _, v := range tests {
 		m = mockEnvironmentsWith(t, v.appName, []string{v.nameOld})
-		err = m.SetEnvironment(v.nameOld, &Environment{Name: v.nameNew})
+		err = m.SetEnvironment(v.nameOld, v.nameNew)
 		if err != nil {
 			t.Fatalf("Could not set '%s', got:\n  %s", v.nameOld, err)
 		}
diff --git a/metadata/interface.go b/metadata/interface.go
index 4dddf18d..d440f60f 100644
--- a/metadata/interface.go
+++ b/metadata/interface.go
@@ -46,7 +46,7 @@ type AbsPaths []string
 type Manager interface {
 	Root() AbsPath
 	LibPaths() (libPath, vendorPath AbsPath)
-	EnvPaths(env string) (metadataPath, mainPath, paramsPath, specPath AbsPath)
+	EnvPaths(env string) (metadataPath, mainPath, paramsPath AbsPath)
 
 	// Components API.
 	ComponentPaths() (AbsPaths, error)
@@ -68,12 +68,13 @@ type Manager interface {
 	// Environment API.
 	CreateEnvironment(name, uri, namespace string, spec ClusterSpec) error
 	DeleteEnvironment(name string) error
-	GetEnvironments() ([]*Environment, error)
-	GetEnvironment(name string) (*Environment, error)
-	SetEnvironment(name string, desired *Environment) error
+	GetEnvironments() (app.EnvironmentSpecs, error)
+	GetEnvironment(name string) (*app.EnvironmentSpec, error)
+	SetEnvironment(name, desiredName string) error
 
 	// Spec API.
 	AppSpec() (*app.Spec, error)
+	WriteAppSpec(*app.Spec) error
 
 	// Dependency/registry API.
 	AddRegistry(name, protocol, uri, version string) (*registry.Spec, error)
diff --git a/metadata/manager.go b/metadata/manager.go
index 8423f16a..58c2d12f 100644
--- a/metadata/manager.go
+++ b/metadata/manager.go
@@ -207,7 +207,7 @@ func (m *manager) LibPaths() (libPath, vendorPath AbsPath) {
 	return m.libPath, m.vendorPath
 }
 
-func (m *manager) EnvPaths(env string) (metadataPath, mainPath, paramsPath, specPath AbsPath) {
+func (m *manager) EnvPaths(env string) (metadataPath, mainPath, paramsPath AbsPath) {
 	envPath := appendToAbsPath(m.environmentsPath, env)
 
 	// .metadata directory
@@ -216,40 +216,10 @@ func (m *manager) EnvPaths(env string) (metadataPath, mainPath, paramsPath, spec
 	mainPath = appendToAbsPath(envPath, envFileName)
 	// params.libsonnet file
 	paramsPath = appendToAbsPath(envPath, componentParamsFile)
-	// spec.json file
-	specPath = appendToAbsPath(envPath, specFilename)
 
 	return
 }
 
-// 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{}
-	}
-
-	return schema, nil
-}
-
 func (m *manager) createUserDirTree() error {
 	dirPaths := []AbsPath{
 		m.userKsonnetRootPath,
diff --git a/metadata/manager_test.go b/metadata/manager_test.go
index 88175f95..254bced3 100644
--- a/metadata/manager_test.go
+++ b/metadata/manager_test.go
@@ -226,10 +226,9 @@ func TestEnvPaths(t *testing.T) {
 	expectedMetadataPath := path.Join(appName, environmentsDir, mockEnvName, metadataDirName)
 	expectedMainPath := path.Join(appName, environmentsDir, mockEnvName, envFileName)
 	expectedParamsPath := path.Join(appName, environmentsDir, mockEnvName, paramsFileName)
-	expectedSpecPath := path.Join(appName, environmentsDir, mockEnvName, specFilename)
 	m := mockEnvironments(t, appName)
 
-	metadataPath, mainPath, paramsPath, specPath := m.EnvPaths(mockEnvName)
+	metadataPath, mainPath, paramsPath := m.EnvPaths(mockEnvName)
 
 	if string(metadataPath) != expectedMetadataPath {
 		t.Fatalf("Expected environment metadata dir path to be:\n  '%s'\n, got:\n  '%s'", expectedMetadataPath, metadataPath)
@@ -240,9 +239,6 @@ func TestEnvPaths(t *testing.T) {
 	if string(paramsPath) != expectedParamsPath {
 		t.Fatalf("Expected environment params path to be:\n  '%s'\n, got:\n  '%s'", expectedParamsPath, paramsPath)
 	}
-	if string(specPath) != expectedSpecPath {
-		t.Fatalf("Expected environment spec path to be:\n  '%s'\n, got:\n  '%s'", expectedSpecPath, specPath)
-	}
 }
 
 func TestFindFailure(t *testing.T) {
diff --git a/pkg/kubecfg/env.go b/pkg/kubecfg/env.go
index ac180ebd..67e26439 100644
--- a/pkg/kubecfg/env.go
+++ b/pkg/kubecfg/env.go
@@ -21,6 +21,8 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/ksonnet/ksonnet/metadata/app"
+
 	log "github.com/sirupsen/logrus"
 
 	"github.com/ksonnet/ksonnet/metadata"
@@ -78,28 +80,38 @@ func NewEnvListCmd(manager metadata.Manager) (*EnvListCmd, error) {
 
 func (c *EnvListCmd) Run(out io.Writer) error {
 	const (
-		nameHeader      = "NAME"
-		namespaceHeader = "NAMESPACE"
-		serverHeader    = "SERVER"
+		nameHeader       = "NAME"
+		k8sVersionHeader = "KUBERNETES-VERSION"
+		namespaceHeader  = "NAMESPACE"
+		serverHeader     = "SERVER"
 	)
 
-	envs, err := c.manager.GetEnvironments()
+	envMap, err := c.manager.GetEnvironments()
 	if err != nil {
 		return err
 	}
 
+	envs := make([]app.EnvironmentSpec, len(envMap))
+	for _, e := range envMap {
+		envs = append(envs, *e)
+	}
+
 	// Sort environments by ascending alphabetical name
 	sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
 
 	rows := [][]string{
-		[]string{nameHeader, namespaceHeader, serverHeader},
+		[]string{nameHeader, k8sVersionHeader, namespaceHeader, serverHeader},
 		[]string{
 			strings.Repeat("=", len(nameHeader)),
+			strings.Repeat("=", len(k8sVersionHeader)),
 			strings.Repeat("=", len(namespaceHeader)),
 			strings.Repeat("=", len(serverHeader))},
 	}
+
 	for _, env := range envs {
-		rows = append(rows, []string{env.Name, env.Namespace, env.Server})
+		for _, dest := range env.Destinations {
+			rows = append(rows, []string{env.Name, env.KubernetesVersion, dest.Namespace, dest.Server})
+		}
 	}
 
 	formattedEnvsList, err := utils.PadRows(rows)
@@ -114,21 +126,16 @@ func (c *EnvListCmd) Run(out io.Writer) error {
 // ==================================================================
 
 type EnvSetCmd struct {
-	name string
-
-	desiredName      string
-	desiredServer    string
-	desiredNamespace string
+	name        string
+	desiredName string
 
 	manager metadata.Manager
 }
 
-func NewEnvSetCmd(name, desiredName, desiredServer, desiredNamespace string, manager metadata.Manager) (*EnvSetCmd, error) {
-	return &EnvSetCmd{name: name, desiredName: desiredName, desiredServer: desiredServer, desiredNamespace: desiredNamespace,
-		manager: manager}, nil
+func NewEnvSetCmd(name, desiredName string, manager metadata.Manager) (*EnvSetCmd, error) {
+	return &EnvSetCmd{name: name, desiredName: desiredName, manager: manager}, nil
 }
 
 func (c *EnvSetCmd) Run() error {
-	desired := metadata.Environment{Name: c.desiredName, Server: c.desiredServer, Namespace: c.desiredNamespace}
-	return c.manager.SetEnvironment(c.name, &desired)
+	return c.manager.SetEnvironment(c.name, c.desiredName)
 }
diff --git a/testdata/testapp/app.yaml b/testdata/testapp/app.yaml
index 5a24585d..2240d9f3 100644
--- a/testdata/testapp/app.yaml
+++ b/testdata/testapp/app.yaml
@@ -8,4 +8,11 @@ registries:
       refSpec: test-reg
     protocol: github
     uri: github.com/ksonnet/parts/tree/test-reg/incubator
+environments:
+  default:
+    destinations:
+    - namespace: foo
+      server: foo
+    k8sVersion: "1.8.1"
+    path: default
 version: 0.0.1
-- 
GitLab