Ver Fonte

Merge pull request #2313 from porter-dev/nafees/api-v1

[POR-672] Add env group endpoints to API v1
Mohammed Nafees há 3 anos atrás
pai
commit
2254471dbc

+ 76 - 0
api/server/handlers/v1/env_group/add_release.go

@@ -0,0 +1,76 @@
+package env_group
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type AddReleaseToEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewAddReleaseToEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AddReleaseToEnvGroupHandler {
+	return &AddReleaseToEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *AddReleaseToEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.V1EnvGroupReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the attached configmap
+	cm, _, err := agent.GetLatestVersionedConfigMap(name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.AddApplicationToVersionedConfigMap(cm, request.ReleaseName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 399 - 0
api/server/handlers/v1/env_group/create.go

@@ -0,0 +1,399 @@
+package env_group
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"sync"
+
+	"sigs.k8s.io/yaml"
+
+	"helm.sh/helm/v3/pkg/release"
+	v1 "k8s.io/api/core/v1"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type CreateEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateEnvGroupHandler {
+	return &CreateEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.CreateEnvGroupRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, 0)
+
+	// if the environment group exists and has MetaVersion=1, throw an error
+	if envGroup != nil && envGroup.MetaVersion == 1 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group with that name already exists"),
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
+		Name:            request.Name,
+		Namespace:       namespace,
+		Variables:       request.Variables,
+		SecretVariables: request.SecretVariables,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err = envgroup.ToEnvGroup(configMap)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	releases, err := envgroup.GetSyncedReleases(helmAgent, configMap)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.V1EnvGroupResponse{
+		CreatedAt: envGroup.CreatedAt,
+		Version:   envGroup.Version,
+		Name:      envGroup.Name,
+		Releases:  envGroup.Applications,
+		Variables: envGroup.Variables,
+	}
+
+	stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err == nil && len(stackId) > 0 {
+		res.StackID = stackId
+	}
+
+	c.WriteResult(w, r, res)
+
+	// trigger rollout of new applications after writing the result
+	errors := rolloutApplications(c.Config(), cluster, helmAgent, envGroup, configMap, releases)
+
+	if len(errors) > 0 {
+		errStrArr := make([]string, 0)
+
+		for _, err := range errors {
+			errStrArr = append(errStrArr, err.Error())
+		}
+
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf(strings.Join(errStrArr, ","))))
+		return
+	}
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+func rolloutApplications(
+	config *config.Config,
+	cluster *models.Cluster,
+	helmAgent *helm.Agent,
+	envGroup *types.EnvGroup,
+	configMap *v1.ConfigMap,
+	releases []*release.Release,
+) []error {
+	registries, err := config.Repo.Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		return []error{err}
+	}
+
+	// construct the synced env section that should be written
+	newSection := &SyncedEnvSection{
+		Name:    envGroup.Name,
+		Version: envGroup.Version,
+	}
+
+	newSectionKeys := make([]SyncedEnvSectionKey, 0)
+
+	for key, val := range configMap.Data {
+		newSectionKeys = append(newSectionKeys, SyncedEnvSectionKey{
+			Name:   key,
+			Secret: strings.Contains(val, "PORTERSECRET"),
+		})
+	}
+
+	newSection.Keys = newSectionKeys
+
+	// asynchronously update releases with that image repo uri
+	var wg sync.WaitGroup
+	mu := &sync.Mutex{}
+	errors := make([]error, 0)
+
+	for i, rel := range releases {
+		index := i
+		release := rel
+		wg.Add(1)
+
+		go func() {
+			defer wg.Done()
+			// read release via agent
+			newConfig, err := getNewConfig(release.Config, newSection)
+
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err)
+				mu.Unlock()
+				return
+			}
+
+			// if this is a job chart, update the config and set correct paused param to true
+			if release.Chart.Name() == "job" {
+				newConfig["paused"] = true
+			}
+
+			conf := &helm.UpgradeReleaseConfig{
+				Name:       releases[index].Name,
+				Cluster:    cluster,
+				Repo:       config.Repo,
+				Registries: registries,
+				Values:     newConfig,
+			}
+
+			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf)
+
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err)
+				mu.Unlock()
+				return
+			}
+		}()
+	}
+
+	wg.Wait()
+
+	return errors
+}
+
+type SyncedEnvSection struct {
+	Name    string                `json:"name" yaml:"name"`
+	Version uint                  `json:"version" yaml:"version"`
+	Keys    []SyncedEnvSectionKey `json:"keys" yaml:"keys"`
+}
+
+type SyncedEnvSectionKey struct {
+	Name   string `json:"name" yaml:"name"`
+	Secret bool   `json:"secret" yaml:"secret"`
+}
+
+func getNewConfig(curr map[string]interface{}, syncedEnvSection *SyncedEnvSection) (map[string]interface{}, error) {
+	// look for container.env.synced
+	envConf, err := getNestedMap(curr, "container", "env")
+
+	if err != nil {
+		return nil, err
+	}
+
+	syncedEnvInter, syncedEnvExists := envConf["synced"]
+
+	if !syncedEnvExists {
+		return curr, nil
+	} else {
+		syncedArr := make([]*SyncedEnvSection, 0)
+		syncedArrInter, ok := syncedEnvInter.([]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("could not convert to synced env section: not an array")
+		}
+
+		for _, syncedArrInterObj := range syncedArrInter {
+			syncedArrObj := &SyncedEnvSection{}
+			syncedArrInterObjMap, ok := syncedArrInterObj.(map[string]interface{})
+
+			if !ok {
+				continue
+			}
+
+			if nameField, nameFieldExists := syncedArrInterObjMap["name"]; nameFieldExists {
+				syncedArrObj.Name, ok = nameField.(string)
+
+				if !ok {
+					continue
+				}
+			}
+
+			if versionField, versionFieldExists := syncedArrInterObjMap["version"]; versionFieldExists {
+				versionFloat, ok := versionField.(float64)
+
+				if !ok {
+					continue
+				}
+
+				syncedArrObj.Version = uint(versionFloat)
+			}
+
+			if keyField, keyFieldExists := syncedArrInterObjMap["keys"]; keyFieldExists {
+				keyFieldInterArr, ok := keyField.([]interface{})
+
+				if !ok {
+					continue
+				}
+
+				keyFieldMapArr := make([]map[string]interface{}, 0)
+
+				for _, keyFieldInter := range keyFieldInterArr {
+					mapConv, ok := keyFieldInter.(map[string]interface{})
+
+					if !ok {
+						continue
+					}
+
+					keyFieldMapArr = append(keyFieldMapArr, mapConv)
+				}
+
+				keyFieldRes := make([]SyncedEnvSectionKey, 0)
+
+				for _, keyFieldMap := range keyFieldMapArr {
+					toAdd := SyncedEnvSectionKey{}
+
+					if nameField, nameFieldExists := keyFieldMap["name"]; nameFieldExists {
+						toAdd.Name, ok = nameField.(string)
+
+						if !ok {
+							continue
+						}
+					}
+
+					if secretField, secretFieldExists := keyFieldMap["secret"]; secretFieldExists {
+						toAdd.Secret, ok = secretField.(bool)
+
+						if !ok {
+							continue
+						}
+					}
+
+					keyFieldRes = append(keyFieldRes, toAdd)
+				}
+
+				syncedArrObj.Keys = keyFieldRes
+			}
+
+			syncedArr = append(syncedArr, syncedArrObj)
+		}
+
+		resArr := make([]SyncedEnvSection, 0)
+		foundMatch := false
+
+		for _, candidate := range syncedArr {
+			if candidate.Name == syncedEnvSection.Name {
+				resArr = append(resArr, *syncedEnvSection)
+				foundMatch = true
+			} else {
+				resArr = append(resArr, *candidate)
+			}
+		}
+
+		if !foundMatch {
+			return curr, nil
+		}
+
+		envConf["synced"] = resArr
+	}
+
+	// to remove all types that Helm may not be able to work with, we marshal to and from
+	// yaml for good measure. Otherwise we get silly error messages like:
+	// Upgrade failed: template: web/templates/deployment.yaml:138:40: executing \"web/templates/deployment.yaml\"
+	// at <$syncedEnv.keys>: can't evaluate field keys in type namespace.SyncedEnvSection
+	currYAML, err := yaml.Marshal(curr)
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make(map[string]interface{})
+
+	err = yaml.Unmarshal([]byte(currYAML), &res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+
+	for _, field := range fields {
+		objField, ok := curr[field]
+
+		if !ok {
+			return nil, fmt.Errorf("%s not found", field)
+		}
+
+		res, ok = objField.(map[string]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	return res, nil
+}
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, envGroup *types.EnvGroup) error {
+	// update the relevant env group version number if tied to a stack resource
+	return stacks.UpdateEnvGroupVersion(config, projectID, clusterID, envGroup)
+}

+ 91 - 0
api/server/handlers/v1/env_group/delete.go

@@ -0,0 +1,91 @@
+package env_group
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DeleteEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteEnvGroupHandler {
+	return &DeleteEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DeleteEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get the env group: if it's MetaVersion=2, return an error
+	envGroup, err := envgroup.GetEnvGroup(agent, name, namespace, 0)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if envGroup != nil && envGroup.MetaVersion == 1 {
+		if err := deleteV1ConfigMap(agent, name, namespace); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if envGroup != nil && envGroup.MetaVersion == 2 {
+		if len(envGroup.Applications) != 0 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("env group must not have any connected releases"),
+				http.StatusPreconditionFailed,
+			))
+
+			return
+		} else if err = envgroup.DeleteEnvGroup(agent, name, namespace); err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}
+
+func deleteV1ConfigMap(agent *kubernetes.Agent, name, namespace string) error {
+	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
+		return err
+	}
+
+	if err := agent.DeleteConfigMap(name, namespace); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 88 - 0
api/server/handlers/v1/env_group/get.go

@@ -0,0 +1,88 @@
+package env_group
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type GetEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetEnvGroupHandler {
+	return &GetEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	version, reqErr := requestutils.GetURLParamUint(r, types.URLParamEnvGroupVersion)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroup, err := envgroup.GetEnvGroup(agent, name, namespace, version)
+
+	if err != nil {
+		if strings.Contains(err.Error(), "not found") {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.V1EnvGroupResponse{
+		CreatedAt: envGroup.CreatedAt,
+		Version:   envGroup.Version,
+		Name:      envGroup.Name,
+		Releases:  envGroup.Applications,
+		Variables: envGroup.Variables,
+	}
+
+	stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err == nil && len(stackId) > 0 {
+		res.StackID = stackId
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 95 - 0
api/server/handlers/v1/env_group/get_all_versions.go

@@ -0,0 +1,95 @@
+package env_group
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type GetEnvGroupAllVersionsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetEnvGroupAllVersionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetEnvGroupAllVersionsHandler {
+	return &GetEnvGroupAllVersionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetEnvGroupAllVersionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	configMaps, err := agent.ListVersionedConfigMaps(name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("env group not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.V1EnvGroupsAllVersionsResponse
+
+	for _, cm := range configMaps {
+		eg, err := envgroup.ToEnvGroup(&cm)
+
+		if err != nil {
+			continue
+		}
+
+		elem := &types.V1EnvGroupResponse{
+			CreatedAt: eg.CreatedAt,
+			Version:   eg.Version,
+			Name:      eg.Name,
+			Releases:  eg.Applications,
+			Variables: eg.Variables,
+		}
+
+		stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, eg)
+
+		if err == nil && len(stackId) > 0 {
+			elem.StackID = stackId
+		}
+
+		res = append(res, elem)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 104 - 0
api/server/handlers/v1/env_group/list.go

@@ -0,0 +1,104 @@
+package env_group
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type ListEnvGroupsHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListEnvGroupsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListEnvGroupsHandler {
+	return &ListEnvGroupsHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListEnvGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get all versioned config maps
+	configMaps, err := agent.ListAllVersionedConfigMaps(namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.V1ListAllEnvGroupsResponse
+
+	for _, cm := range configMaps {
+		eg, err := envgroup.ToEnvGroup(&cm)
+
+		if err != nil {
+			continue
+		}
+
+		elem := &types.V1EnvGroupMeta{
+			CreatedAt: eg.CreatedAt,
+			Name:      eg.Name,
+		}
+
+		stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, eg)
+
+		if err == nil && len(stackId) > 0 {
+			elem.StackID = stackId
+		}
+
+		res = append(res, elem)
+	}
+
+	// get all meta-version 1 configmaps
+	configMapList, err := agent.ListConfigMaps(namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, v1CM := range configMapList.Items {
+		eg, err := envgroup.ToEnvGroup(&v1CM)
+
+		if err != nil {
+			continue
+		}
+
+		elem := &types.V1EnvGroupMeta{
+			CreatedAt: eg.CreatedAt,
+			Name:      eg.Name,
+		}
+
+		stackId, err := stacks.GetStackForEnvGroup(c.Config(), cluster.ProjectID, cluster.ID, eg)
+
+		if err == nil && len(stackId) > 0 {
+			elem.StackID = stackId
+		}
+
+		res = append(res, elem)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 79 - 0
api/server/handlers/v1/env_group/remove_release.go

@@ -0,0 +1,79 @@
+package env_group
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RemoveReleaseFromEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRemoveReleaseFromEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RemoveReleaseFromEnvGroupHandler {
+	return &RemoveReleaseFromEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RemoveReleaseFromEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamEnvGroupName)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.V1EnvGroupReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the attached configmap
+	cm, _, err := agent.GetLatestVersionedConfigMap(name, namespace)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.RemoveApplicationFromVersionedConfigMap(cm, request.ReleaseName)
+
+	if err != nil && errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("env group not found")))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 6 - 1
api/server/router/router.go

@@ -120,7 +120,12 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
 		v1StackRegisterer := v1.NewV1StackScopedRegisterer()
-		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer, v1StackRegisterer)
+		v1EnvGroupRegisterer := v1.NewV1EnvGroupScopedRegisterer()
+		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(
+			v1ReleaseRegisterer,
+			v1StackRegisterer,
+			v1EnvGroupRegisterer,
+		)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 			v1ClusterRegisterer,

+ 506 - 0
api/server/router/v1/env_group.go

@@ -0,0 +1,506 @@
+package v1
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi"
+	v1EnvGroup "github.com/porter-dev/porter/api/server/handlers/v1/env_group"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// swagger:parameters getEnvGroup
+type envGroupVersionPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The env group name
+	// in: path
+	// required: true
+	Name string `json:"name"`
+
+	// The version of the env group. 0 means latest version.
+	// in: path
+	// required: true
+	// minimum: 0
+	Version uint `json:"version"`
+}
+
+// swagger:parameters getEnvGroupAllVersions deleteEnvGroup addReleaseToEnvGroup removeReleaseFromEnvGroup
+type envGroupPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The env group name
+	// in: path
+	// required: true
+	Name string `json:"name"`
+}
+
+func NewV1EnvGroupScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1EnvGroupScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1EnvGroupScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1EnvGroupRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1EnvGroupRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/env_groups"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups -> namespace.NewCreateEnvGroupHandler
+	// swagger:operation PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups createOrUpdateEnvGroup
+	//
+	// Creates a new env group or updates an existing one in the namespace denoted by `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id`. The cluster should belong to the project denoted by `project_id`.
+	//
+	// **Note:** If updating an existing env group, the linked releases with the env group will all be updated as well.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create or update an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateEnvGroupRequest
+	//     description: The env group to create or update
+	//     schema:
+	//       $ref: '#/definitions/CreateEnvGroupRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully created a new namespace
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupResponse'
+	//   '403':
+	//     description: Forbidden
+	createOrUpdateEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPut,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createOrUpdateEnvGroupHandler := v1EnvGroup.NewCreateEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createOrUpdateEnvGroupEndpoint,
+		Handler:  createOrUpdateEnvGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/versions/{version} -> env_group.NewGetEnvGroupHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/versions/{version} getEnvGroup
+	//
+	// Gets an env group denoted by `name` and `version` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`. **Note:** To get the latest version of an env group, set `version` to `0`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '200':
+	//     description: Successfully fetched the env group
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	getEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/versions/{%s}", relPath, types.URLParamEnvGroupName,
+					types.URLParamEnvGroupVersion),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getEnvGroupHandler := v1EnvGroup.NewGetEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEnvGroupEndpoint,
+		Handler:  getEnvGroupHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} -> env_group.NewGetEnvGroupAllVersionsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} getEnvGroupAllVersions
+	//
+	// Gets all versions of an env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get all versions of an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	// responses:
+	//   '200':
+	//     description: Successfully fetched the env group
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupsAllVersionsResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	getEnvGroupAllVersionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getEnvGroupAllVersionsHandler := v1EnvGroup.NewGetEnvGroupAllVersionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEnvGroupAllVersionsEndpoint,
+		Handler:  getEnvGroupAllVersionsHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups -> namespace.NewListEnvGroupsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups listAllEnvGroups
+	//
+	// Gets all env groups in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get all env groups
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully fetched the env group
+	//     schema:
+	//       $ref: '#/definitions/V1ListAllEnvGroupsResponse'
+	//   '403':
+	//     description: Forbidden
+	listEnvGroupsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	listEnvGroupsHandler := v1EnvGroup.NewListEnvGroupsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvGroupsEndpoint,
+		Handler:  listEnvGroupsHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} -> env_group.NewDeleteEnvGroupHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name} deleteEnvGroup
+	//
+	// Deletes the env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the env group
+	//   '403':
+	//     description: Forbidden
+	//   '412':
+	//     description: Env group is linked to one or more releases
+	deleteEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	deleteEnvGroupHandler := v1EnvGroup.NewDeleteEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEnvGroupEndpoint,
+		Handler:  deleteEnvGroupHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/add_release -> env_group.NewAddReleaseToEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/add_release addReleaseToEnvGroup
+	//
+	// Adds a release to the env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add a release to an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - in: body
+	//     name: V1EnvGroupReleaseRequest
+	//     description: The name of the release to add
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupReleaseRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the release
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	addReleaseToEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/add_release", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	addReleaseToEnvGroupHandler := v1EnvGroup.NewAddReleaseToEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addReleaseToEnvGroupEndpoint,
+		Handler:  addReleaseToEnvGroupHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/remove_release -> env_group.NewRemoveReleaseFromEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/env_groups/{name}/remove_release removeReleaseFromEnvGroup
+	//
+	// Removes a release from the env group denoted by `name` in the namespace denoted by `namespace`. The namespace should belong to the cluster denoted by
+	// `cluster_id`, which in turn should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove a release from an env group
+	// tags:
+	// - Env groups
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - in: body
+	//     name: V1EnvGroupReleaseRequest
+	//     description: The name of the release to remove
+	//     schema:
+	//       $ref: '#/definitions/V1EnvGroupReleaseRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully removed the release
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Env group not found
+	removeReleaseFromEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/remove_release", relPath, types.URLParamEnvGroupName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	removeReleaseFromEnvGroupHandler := v1EnvGroup.NewRemoveReleaseFromEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeReleaseFromEnvGroupEndpoint,
+		Handler:  removeReleaseFromEnvGroupHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 1 - 1
api/server/router/v1/namespace.go

@@ -8,7 +8,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks
+// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks createOrUpdateEnvGroup listAllEnvGroups
 type namespacePathParams struct {
 	// The project id
 	// in: path

+ 67 - 5
api/types/namespace.go

@@ -9,8 +9,10 @@ import (
 )
 
 const (
-	URLParamPodName     URLParam = "name"
-	URLParamIngressName URLParam = "name"
+	URLParamPodName         URLParam = "name"
+	URLParamIngressName     URLParam = "name"
+	URLParamEnvGroupName    URLParam = "name"
+	URLParamEnvGroupVersion URLParam = "version"
 )
 
 // ReleaseListFilter is a struct that represents the various filter options used for
@@ -153,10 +155,19 @@ type AddEnvGroupApplicationRequest struct {
 
 type ListEnvGroupsResponse []*EnvGroupMeta
 
+// CreateEnvGroupRequest represents the request body to create or update an env group
+//
+// swagger:model
 type CreateEnvGroupRequest struct {
-	Name            string            `json:"name,required"`
-	Variables       map[string]string `json:"variables,required"`
-	SecretVariables map[string]string `json:"secret_variables,required"`
+	// the name of the env group to create or update
+	// example: prod-env-group
+	Name string `json:"name" form:"required"`
+
+	// the variables to include in the env group
+	Variables map[string]string `json:"variables" form:"required"`
+
+	// the secret variables to include in the env group
+	SecretVariables map[string]string `json:"secret_variables"`
 }
 
 type CreateConfigMapResponse struct {
@@ -215,3 +226,54 @@ type GetEnvGroupResponse struct {
 	*EnvGroup
 	StackID string `json:"stack_id,omitempty"`
 }
+
+// V1EnvGroupReleaseRequest represents the request body to add or remove a release in an env group
+//
+// swagger:model
+type V1EnvGroupReleaseRequest struct {
+	ReleaseName string `json:"release_name" form:"required"`
+}
+
+// V1EnvGroupResponse defines an env group
+//
+// swagger:model
+type V1EnvGroupResponse struct {
+	// the UTC timestamp in RFC 3339 format indicating the creation time of the env group
+	CreatedAt time.Time `json:"created_at"`
+
+	// the version of the env group
+	Version uint `json:"version"`
+
+	// the name of the env group
+	Name string `json:"name"`
+
+	// the list of releases linked to this env group
+	Releases []string `json:"releases"`
+
+	// the variables contained in this env group
+	Variables map[string]string `json:"variables"`
+
+	// the ID of the stack containing this env group (if any)
+	StackID string `json:"stack_id,omitempty"`
+}
+
+// V1EnvGroupsAllVersionsResponse represents the response body containing all versions of an env group
+//
+// swagger:model
+type V1EnvGroupsAllVersionsResponse []*V1EnvGroupResponse
+
+type V1EnvGroupMeta struct {
+	// the UTC timestamp in RFC 3339 format indicating the creation time of the env group
+	CreatedAt time.Time `json:"created_at"`
+
+	// the name of the env group
+	Name string `json:"name"`
+
+	// the ID of the stack containing this env group (if any)
+	StackID string `json:"stack_id,omitempty"`
+}
+
+// V1ListAllEnvGroupsResponse represents the response body containing the list of env groups
+//
+// swagger:model
+type V1ListAllEnvGroupsResponse []*V1EnvGroupMeta