Alexander Belanger 4 лет назад
Родитель
Сommit
0f9d70975c
37 измененных файлов с 987 добавлено и 171 удалено
  1. 2 2
      api/client/k8s.go
  2. 19 4
      api/server/handlers/cluster/create_namespace.go
  3. 5 10
      api/server/handlers/cluster/delete_namespace.go
  4. 13 2
      api/server/handlers/cluster/get_namespace.go
  5. 15 2
      api/server/handlers/cluster/list_namespaces.go
  6. 1 1
      api/server/handlers/handler.go
  7. 1 1
      api/server/handlers/project_oauth/digitalocean.go
  8. 1 1
      api/server/handlers/project_oauth/slack.go
  9. 112 3
      api/server/handlers/registry/create.go
  10. 2 0
      api/server/handlers/registry/create_repository.go
  11. 3 1
      api/server/handlers/release/create.go
  12. 1 1
      api/server/handlers/release/delete.go
  13. 3 3
      api/server/handlers/release/update_rollback.go
  14. 2 2
      api/server/handlers/release/upgrade.go
  15. 94 0
      api/server/handlers/v1/registry/list_images.go
  16. 235 0
      api/server/handlers/v1/release/upgrade.go
  17. 2 2
      api/server/router/cluster.go
  18. 2 2
      api/server/router/router.go
  19. 13 6
      api/server/router/v1/cluster.go
  20. 42 10
      api/server/router/v1/registry.go
  21. 26 7
      api/server/router/v1/release.go
  22. 11 4
      api/types/build_config.go
  23. 24 20
      api/types/cluster.go
  24. 28 9
      api/types/git_action_config.go
  25. 1 1
      api/types/policy.go
  26. 82 10
      api/types/registry.go
  27. 62 16
      api/types/release.go
  28. 1 1
      api/types/user.go
  29. 5 3
      cli/cmd/cluster.go
  30. 5 2
      cli/cmd/get.go
  31. 6 6
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  32. 5 5
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  33. 4 4
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  34. 2 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  35. 4 5
      dashboard/src/shared/api.tsx
  36. 1 1
      internal/models/registry.go
  37. 152 23
      internal/registry/registry.go

+ 2 - 2
api/client/k8s.go

@@ -38,8 +38,8 @@ func (c *Client) CreateNewK8sNamespace(
 	projectID uint,
 	clusterID uint,
 	name string,
-) (*types.CreateNamespaceResponse, error) {
-	resp := &types.CreateNamespaceResponse{}
+) (*types.NamespaceResponse, error) {
+	resp := &types.NamespaceResponse{}
 
 	err := c.postRequest(
 		fmt.Sprintf(

+ 19 - 4
api/server/handlers/cluster/create_namespace.go

@@ -1,7 +1,9 @@
 package cluster
 
 import (
+	"fmt"
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -44,6 +46,15 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	_, err = agent.GetNamespace(request.Name)
+
+	if err == nil { // namespace with name already exists
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("namespace already exists"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
 	namespace, err := agent.CreateNamespace(request.Name)
 
 	if err != nil {
@@ -51,10 +62,14 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	res := types.CreateNamespaceResponse{
-		Metadata: types.CreateNamespaceResponseMeta{
-			Name: namespace.Name,
-		},
+	res := &types.NamespaceResponse{
+		Name:              namespace.Name,
+		CreationTimestamp: namespace.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+		Status:            string(namespace.Status.Phase),
+	}
+
+	if namespace.DeletionTimestamp != nil {
+		res.DeletionTimestamp = namespace.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
 	}
 
 	w.WriteHeader(http.StatusCreated)

+ 5 - 10
api/server/handlers/cluster/delete_namespace.go

@@ -29,20 +29,15 @@ func NewDeleteNamespaceHandler(
 }
 
 func (c *DeleteNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
-
-	if namespace == "" {
-		request := &types.DeleteNamespaceRequest{}
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-		if ok := c.DecodeAndValidate(w, r, request); !ok {
-			return
-		}
+	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
-		namespace = request.Name
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
 	}
 
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
 	agent, err := c.GetAgent(r, cluster, "")
 
 	if err != nil {

+ 13 - 2
api/server/handlers/cluster/get_namespace.go

@@ -2,6 +2,7 @@ package cluster
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -30,7 +31,7 @@ func NewGetNamespaceHandler(
 }
 
 func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
+	ns, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
@@ -45,7 +46,7 @@ func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	res, err := agent.GetNamespace(namespace)
+	namespace, err := agent.GetNamespace(ns)
 
 	if err != nil {
 		if errors.IsNotFound(err) {
@@ -57,5 +58,15 @@ func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	res := &types.NamespaceResponse{
+		Name:              namespace.Name,
+		CreationTimestamp: namespace.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+		Status:            string(namespace.Status.Phase),
+	}
+
+	if namespace.DeletionTimestamp != nil {
+		res.DeletionTimestamp = namespace.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
+	}
+
 	c.WriteResult(w, r, res)
 }

+ 15 - 2
api/server/handlers/cluster/list_namespaces.go

@@ -2,6 +2,7 @@ package cluster
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -44,8 +45,20 @@ func (c *ListNamespacesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	res := types.ListNamespacesResponse{
-		NamespaceList: namespaceList,
+	res := types.ListNamespacesResponse{}
+
+	for _, ns := range namespaceList.Items {
+		namespace := &types.NamespaceResponse{
+			Name:              ns.Name,
+			CreationTimestamp: ns.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+			Status:            string(ns.Status.Phase),
+		}
+
+		if ns.DeletionTimestamp != nil {
+			namespace.DeletionTimestamp = ns.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
+		}
+
+		res = append(res, namespace)
 	}
 
 	c.WriteResult(w, r, res)

+ 1 - 1
api/server/handlers/handler.go

@@ -21,7 +21,7 @@ type PorterHandler interface {
 		w http.ResponseWriter,
 		r *http.Request,
 		state string,
-		isUser, isProject bool,
+		isProject, isUser bool,
 		integrationClient types.OAuthIntegrationClient,
 		integrationID uint,
 	) error

+ 1 - 1
api/server/handlers/project_oauth/digitalocean.go

@@ -29,7 +29,7 @@ func NewProjectOAuthDOHandler(
 func (p *ProjectOAuthDOHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	state := oauth.CreateRandomState()
 
-	if err := p.PopulateOAuthSession(w, r, state, false, true, "", 0); err != nil {
+	if err := p.PopulateOAuthSession(w, r, state, true, false, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 1 - 1
api/server/handlers/project_oauth/slack.go

@@ -29,7 +29,7 @@ func NewProjectOAuthSlackHandler(
 func (p *ProjectOAuthSlackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	state := oauth.CreateRandomState()
 
-	if err := p.PopulateOAuthSession(w, r, state, false, true, "", 0); err != nil {
+	if err := p.PopulateOAuthSession(w, r, state, true, false, "", 0); err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 112 - 3
api/server/handlers/registry/create.go

@@ -1,6 +1,7 @@
 package registry
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
+	"gorm.io/gorm"
 )
 
 type RegistryCreateHandler struct {
@@ -49,7 +51,113 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	//  TODO!!!: validate the credentials here!!!
+	// validate request before saving it to the DB
+	integrationIDs := []uint{
+		request.GCPIntegrationID,
+		request.AWSIntegrationID,
+		request.DOIntegrationID,
+		request.BasicIntegrationID,
+		request.AzureIntegrationID,
+	}
+
+	idCount := 0
+
+	for _, id := range integrationIDs {
+		if id != 0 {
+			idCount += 1
+		}
+	}
+
+	if idCount > 1 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("only one integration ID should be set"), http.StatusBadRequest,
+		))
+		return
+	} else if idCount == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("at least one integration ID should be set"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
+	if request.GCPIntegrationID != 0 {
+		_, err = p.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such GCP integration ID: %d for project ID: %d", request.GCPIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.AWSIntegrationID != 0 {
+		_, err = p.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such AWS integration ID: %d for project ID: %d", request.AWSIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.DOIntegrationID != 0 {
+		_, err = p.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such DO integration ID: %d for project ID: %d", request.DOIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.BasicIntegrationID != 0 {
+		_, err = p.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, request.BasicIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such basic integration ID: %d for project ID: %d", request.BasicIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.AzureIntegrationID != 0 {
+		_, err = p.Repo().AzureIntegration().ReadAzureIntegration(proj.ID, request.AzureIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such Azure integration ID: %d for project ID: %d", request.AzureIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
 	// create a registry model
 	regModel := &models.Registry{
@@ -101,7 +209,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		az.ACRName = request.ACRName
 		az.ACRResourceGroupName = request.ACRResourceGroupName
 
-		az, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
+		_, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
 
 		if err != nil {
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -110,7 +218,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	// handle write to the database
-	regModel, err := p.Repo().Registry().CreateRegistry(regModel)
+	regModel, err = p.Repo().Registry().CreateRegistry(regModel)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -124,5 +232,6 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		},
 	))
 
+	w.WriteHeader(http.StatusCreated)
 	p.WriteResult(w, r, regModel.ToRegistryType())
 }

+ 2 - 0
api/server/handlers/registry/create_repository.go

@@ -51,4 +51,6 @@ func (p *RegistryCreateRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
+
+	w.WriteHeader(http.StatusCreated)
 }

+ 3 - 1
api/server/handlers/release/create.go

@@ -214,6 +214,8 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			FlowID: operationID,
 		},
 	))
+
+	w.WriteHeader(http.StatusCreated)
 }
 
 func CreateAppReleaseFromHelmRelease(
@@ -554,7 +556,7 @@ type containerEnvConfig struct {
 	} `yaml:"container"`
 }
 
-func getGARunner(
+func GetGARunner(
 	config *config.Config,
 	userID, projectID, clusterID uint,
 	ga *models.GitActionConfig,

+ 1 - 1
api/server/handlers/release/delete.go

@@ -88,7 +88,7 @@ func (c *DeleteReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 						return
 					}
 				} else {
-					gaRunner, err := getGARunner(
+					gaRunner, err := GetGARunner(
 						c.Config(),
 						user.ID,
 						cluster.ProjectID,

+ 3 - 3
api/server/handlers/release/update_rollback.go

@@ -65,7 +65,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		rel, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
 
 		if err == nil && rel != nil {
-			err = updateReleaseRepo(c.Config(), rel, helmRelease)
+			err = UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -75,7 +75,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			gitAction := rel.GitActionConfig
 
 			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
-				gaRunner, err := getGARunner(
+				gaRunner, err := GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,
@@ -110,7 +110,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 }
 
-func updateReleaseRepo(config *config.Config, release *models.Release, helmRelease *release.Release) error {
+func UpdateReleaseRepo(config *config.Config, release *models.Release, helmRelease *release.Release) error {
 	repository := helmRelease.Config["image"].(map[string]interface{})["repository"]
 	repoStr, ok := repository.(string)
 

+ 2 - 2
api/server/handlers/release/ugprade.go → api/server/handlers/release/upgrade.go

@@ -187,7 +187,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	// update the github actions env if the release exists and is built from source
 	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
 		if releaseErr == nil && rel != nil {
-			err = updateReleaseRepo(c.Config(), rel, helmRelease)
+			err = UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -197,7 +197,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			gitAction := rel.GitActionConfig
 
 			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
-				gaRunner, err := getGARunner(
+				gaRunner, err := GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,

+ 94 - 0
api/server/handlers/v1/registry/list_images.go

@@ -0,0 +1,94 @@
+package registry
+
+import (
+	"fmt"
+	"net/http"
+
+	"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/models"
+	"github.com/porter-dev/porter/internal/registry"
+)
+
+type RegistryListImagesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRegistryListImagesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RegistryListImagesHandler {
+	return &RegistryListImagesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RegistryListImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	reg, _ := r.Context().Value(types.RegistryScope).(*models.Registry)
+
+	repoName, reqErr := requestutils.GetURLParamString(r, types.URLParamWildcard)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
+	}
+
+	request := &types.V1ListImageRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	res := &types.V1ListImageResponse{}
+
+	// cast to a registry from registry package
+	_reg := registry.Registry(*reg)
+	regAPI := &_reg
+
+	if regAPI.AWSIntegrationID != 0 {
+		if request.Num == 0 {
+			request.Num = 1000
+		} else if request.Num < 1 || request.Num > 1000 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("num should be between 1 and 1000 for ECR images"), http.StatusBadRequest,
+			))
+			return
+		}
+
+		var nextToken *string
+		if request.Next != "" {
+			nextToken = &request.Next
+		}
+
+		imgs, nextToken, err := regAPI.GetECRPaginatedImages(repoName, c.Repo(), request.Num, nextToken)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if nextToken != nil {
+			res.Next = *nextToken
+		}
+
+		res.Images = append(res.Images, imgs...)
+	} else {
+		imgs, err := regAPI.ListImages(repoName, c.Repo(), c.Config().DOConf)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		res.Images = append(res.Images, imgs...)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 235 - 0
api/server/handlers/v1/release/upgrade.go

@@ -0,0 +1,235 @@
+package release
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	semver "github.com/Masterminds/semver/v3"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	baseReleaseHandler "github.com/porter-dev/porter/api/server/handlers/release"
+	"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/integrations/slack"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+var (
+	createEnvSecretConstraint, _ = semver.NewConstraint(" < 0.1.0")
+)
+
+type UpgradeReleaseHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpgradeReleaseHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpgradeReleaseHandler {
+	return &UpgradeReleaseHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.V1UpgradeReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       helmRelease.Name,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+
+	// if the chart version is set, load a chart from the repo
+	if request.ChartVersion != "" {
+		cache := c.Config().URLCache
+		chartRepoURL, foundFirst := cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+		if !foundFirst {
+			cache.Update()
+
+			var found bool
+
+			chartRepoURL, found = cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+			if !found {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("chart not found"),
+					http.StatusBadRequest,
+				))
+
+				return
+			}
+		}
+
+		chart, err := baseReleaseHandler.LoadChart(c.Config(), &baseReleaseHandler.LoadAddonChartOpts{
+			ProjectID:       cluster.ProjectID,
+			RepoURL:         chartRepoURL,
+			TemplateName:    helmRelease.Chart.Metadata.Name,
+			TemplateVersion: request.ChartVersion,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("chart not found"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		conf.Chart = chart
+	}
+
+	conf.Values = request.Values
+
+	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+
+	if upgradeErr == nil && newHelmRelease != nil {
+		helmRelease = newHelmRelease
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, releaseErr := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
+
+	var notifConf *types.NotificationConfig
+	notifConf = nil
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   cluster.ProjectID,
+		ClusterID:   cluster.ID,
+		ClusterName: cluster.Name,
+		Name:        helmRelease.Name,
+		Namespace:   helmRelease.Namespace,
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			c.Config().ServerConf.ServerURL,
+			url.PathEscape(cluster.Name),
+			helmRelease.Namespace,
+			helmRelease.Name,
+			cluster.ProjectID,
+		),
+	}
+
+	if upgradeErr != nil {
+		notifyOpts.Status = slack.StatusHelmFailed
+		notifyOpts.Info = upgradeErr.Error()
+
+		if !cluster.NotificationsDisabled {
+			notifier.Notify(notifyOpts)
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			upgradeErr,
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	if helmRelease.Chart != nil && helmRelease.Chart.Metadata.Name != "job" {
+		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Version = helmRelease.Version
+
+		if !cluster.NotificationsDisabled {
+			notifier.Notify(notifyOpts)
+		}
+	}
+
+	// update the github actions env if the release exists and is built from source
+	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
+		if releaseErr == nil && rel != nil {
+			err = baseReleaseHandler.UpdateReleaseRepo(c.Config(), rel, helmRelease)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			gitAction := rel.GitActionConfig
+
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
+				gaRunner, err := baseReleaseHandler.GetGARunner(
+					c.Config(),
+					user.ID,
+					cluster.ProjectID,
+					cluster.ID,
+					rel.GitActionConfig,
+					helmRelease.Name,
+					helmRelease.Namespace,
+					rel,
+					helmRelease,
+				)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				actionVersion, err := semver.NewVersion(gaRunner.Version)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				if createEnvSecretConstraint.Check(actionVersion) {
+					if err := gaRunner.CreateEnvSecret(); err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+						return
+					}
+				}
+			}
+		}
+	}
+}

+ 2 - 2
api/server/router/cluster.go

@@ -609,14 +609,14 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
-	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/delete -> cluster.NewDeleteNamespaceHandler
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
 	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Method: types.HTTPVerbDelete,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/namespaces/delete",
+				RelativePath: fmt.Sprintf("%s/namespaces/{%s}", relPath, types.URLParamNamespace),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,

+ 2 - 2
api/server/router/router.go

@@ -260,10 +260,10 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(operationFactory.Middleware)
 			case types.ReleaseScope:
 				atomicGroup.Use(releaseFactory.Middleware)
-			case types.GitlabIntegrationScope:
-				atomicGroup.Use(gitlabIntFactory.Middleware)
 			case types.StackScope:
 				atomicGroup.Use(stackFactory.Middleware)
+			case types.GitlabIntegrationScope:
+				atomicGroup.Use(gitlabIntFactory.Middleware)
 			}
 		}
 

+ 13 - 6
api/server/router/v1/cluster.go

@@ -73,7 +73,8 @@ func getV1ClusterRoutes(
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewCreateNamespaceHandler
 	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces createNamespace
 	//
-	// Creates a new namespace
+	// Creates a new namespace in the cluster denoted by `cluster_id`. The cluster should belong to the project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -93,9 +94,11 @@ func getV1ClusterRoutes(
 	//   '201':
 	//     description: Successfully created a new namespace
 	//     schema:
-	//       $ref: '#/definitions/CreateNamespaceResponse'
+	//       $ref: '#/definitions/NamespaceResponse'
 	//   '403':
 	//     description: Forbidden
+	//   '412':
+	//     description: Namespace already exists
 	createNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -127,7 +130,8 @@ func getV1ClusterRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewGetNamespaceHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} getNamespace
 	//
-	// Gets a namespace
+	// Gets a namespace denoted by the name `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -143,7 +147,7 @@ func getV1ClusterRoutes(
 	//   '200':
 	//     description: Successfully got the namespace
 	//     schema:
-	//       $ref: '#/definitions/GetNamespaceResponse'
+	//       $ref: '#/definitions/NamespaceResponse'
 	//   '403':
 	//     description: Forbidden
 	//   '404':
@@ -178,7 +182,8 @@ func getV1ClusterRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewListNamespacesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces listNamespaces
 	//
-	// Lists namespaces
+	// Lists all namespaces in the cluster denoted by `cluster_id`. The cluster should belong to
+	// the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -226,7 +231,9 @@ func getV1ClusterRoutes(
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
 	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} deleteNamespace
 	//
-	// Deletes a namespace
+	// Deletes a namespace with the name `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
+	// Note that this endpoint does not indicate if the namespace does not exist.
 	//
 	// ---
 	// produces:

+ 42 - 10
api/server/router/v1/registry.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
+	v1Registry "github.com/porter-dev/porter/api/server/handlers/v1/registry"
 	"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"
@@ -73,7 +74,7 @@ func getV1RegistryRoutes(
 	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
 	// swagger:operation POST /api/v1/projects/{project_id}/registries createRegistry
 	//
-	// Connects a new image registry
+	// Connects a new image registry to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -93,8 +94,12 @@ func getV1RegistryRoutes(
 	//     description: Successfully connected the registry
 	//     schema:
 	//       $ref: '#/definitions/CreateRegistryResponse'
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
+	//   '404':
+	//     description: A subresource was not found
 	createRegistryEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -125,7 +130,8 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryGetHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id} getRegistry
 	//
-	// Gets an image registry
+	// Gets an image registry denoted by `registry_id`. The registry should belong to the
+	// project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -137,7 +143,7 @@ func getV1RegistryRoutes(
 	//   - name: project_id
 	//   - name: registry_id
 	// responses:
-	//   '201':
+	//   '200':
 	//     description: Successfully got the registry
 	//     schema:
 	//       $ref: '#/definitions/GetRegistryResponse'
@@ -173,7 +179,7 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries listRegistries
 	//
-	// Lists registries
+	// Lists all registries connected to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -220,7 +226,8 @@ func getV1RegistryRoutes(
 	// DELETE /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryDeleteHandler
 	// swagger:operation DELETE /api/v1/projects/{project_id}/registries/{registry_id} deleteRegistry
 	//
-	// Deletes an image registry.
+	// Deletes a registry denoted by `registry_id`. The registry should belong to
+	// the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -319,7 +326,8 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryListRepositoriesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories listRegistryRepositories
 	//
-	// Lists image repositories inside the image registry given by `registry_id`
+	// Lists image repositories inside the image registry denoted by `registry_id`. The registry
+	// should belong to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -367,7 +375,9 @@ func getV1RegistryRoutes(
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/* -> registry.NewRegistryListImagesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/{repository} listRegistryImages
 	//
-	// Lists all images in an image repository.
+	// Lists all images in the image repository denoted by the name `repository`. The repository should belong
+	// to the registry denoted by `registry_id` which should itself belong to the project denoted by
+	// `project_id`.
 	//
 	// ---
 	// produces:
@@ -380,14 +390,35 @@ func getV1RegistryRoutes(
 	//   - name: registry_id
 	//   - name: repository
 	//     in: path
-	//     description: the image repository name
+	//     description: The image repository name
 	//     type: string
 	//     required: true
+	//   - name: num
+	//     in: query
+	//     description: |
+	//       The number of images to list.
+	//       For ECR images, a maximum of 1000 is allowed.
+	//     type: integer
+	//     required: false
+	//     minimum: 1
+	//   - name: next
+	//     in: query
+	//     description: The next page string used for pagination, from a previous request.
+	//     type: string
+	//   - name: page
+	//     in: query
+	//     description: |
+	//       The page number used for pagination, possibly from a previous request.
+	//       (**DigitalOcean only**)
+	//     type: integer
+	//     minimum: 1
 	// responses:
 	//   '200':
 	//     description: Successfully listed images
 	//     schema:
-	//       $ref: '#/definitions/ListImagesResponse'
+	//       $ref: '#/definitions/V1ListImageResponse'
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
 	listImagesEndpoint := factory.NewAPIEndpoint(
@@ -410,8 +441,9 @@ func getV1RegistryRoutes(
 		},
 	)
 
-	listImagesHandler := registry.NewRegistryListImagesHandler(
+	listImagesHandler := v1Registry.NewRegistryListImagesHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 

+ 26 - 7
api/server/router/v1/release.go

@@ -4,6 +4,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/handlers/release"
+	v1Release "github.com/porter-dev/porter/api/server/handlers/v1/release"
 	"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"
@@ -94,7 +95,8 @@ func getV1ReleaseRoutes(
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> release.NewCreateReleaseHandler
 	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases createRelease
 	//
-	// Creates a new release
+	// Creates a new release in the namespace denoted by `namespace`. The namespace should belong to the
+	// cluster denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -114,8 +116,16 @@ func getV1ReleaseRoutes(
 	// responses:
 	//   '201':
 	//     description: Successfully created the release
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
+	//   '404':
+	//     description: A subresource was not found
+	//   '409':
+	//     description: A conflict occurred with another external service
+	//   '412':
+	//     description: A precondition failed for the request
 	createReleaseEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -148,7 +158,9 @@ func getV1ReleaseRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} -> release.NewReleaseGetHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} getRelease
 	//
-	// Gets a release
+	// Gets the release denoted by the name `name` and its version `version`. The release should belong to the namespace
+	// denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -163,7 +175,7 @@ func getV1ReleaseRoutes(
 	//   - name: name
 	//   - name: version
 	// responses:
-	//   '201':
+	//   '200':
 	//     description: Successfully got the release
 	//     schema:
 	//       $ref: '#/definitions/GetReleaseResponse'
@@ -201,7 +213,8 @@ func getV1ReleaseRoutes(
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> namespace.NewListReleasesHandler
 	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases listReleases
 	//
-	// List releases
+	// List all releases in the namespace denoted by `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` and project denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -249,7 +262,9 @@ func getV1ReleaseRoutes(
 	// release.NewUpgradeReleaseHandler
 	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} updateRelease
 	//
-	// Updates a release
+	// Upgrades the release with the name denoted by `name` and version denoted by `version`. The release should belong
+	// to the namespace denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:
@@ -271,6 +286,8 @@ func getV1ReleaseRoutes(
 	// responses:
 	//   '200':
 	//     description: Successfully updated the release
+	//   '400':
+	//     description: A malformed or bad request
 	//   '403':
 	//     description: Forbidden
 	upgradeEndpoint := factory.NewAPIEndpoint(
@@ -291,7 +308,7 @@ func getV1ReleaseRoutes(
 		},
 	)
 
-	upgradeHandler := release.NewUpgradeReleaseHandler(
+	upgradeHandler := v1Release.NewUpgradeReleaseHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -307,7 +324,9 @@ func getV1ReleaseRoutes(
 	// release.NewDeleteReleaseHandler
 	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} deleteRelease
 	//
-	// Deletes a release
+	// Deletes the release with the name denoted by `name` and version denoted by `version`. The release should belong
+	// to the namespace denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
 	//
 	// ---
 	// produces:

+ 11 - 4
api/types/build_config.go

@@ -1,16 +1,23 @@
 package types
 
-// BuildConfig
+// The build configuration for this release when using buildpacks
 type BuildConfig struct {
 	Builder    string   `json:"builder"`
 	Buildpacks []string `json:"buildpacks"`
 	Config     []byte   `json:"config"`
 }
 
+// The build configuration for this new release
 type CreateBuildConfigRequest struct {
-	Builder    string                 `json:"builder" form:"required"`
-	Buildpacks []string               `json:"buildpacks"`
-	Config     map[string]interface{} `json:"config,omitempty"`
+	// The name of the builder to use with `pack` (Heroku or Paketo)
+	// required: true
+	Builder string `json:"builder" form:"required"`
+
+	// The list of buildpacks to use for the release
+	Buildpacks []string `json:"buildpacks"`
+
+	// UNUSED
+	Config map[string]interface{} `json:"config,omitempty"`
 }
 
 type UpdateBuildConfigRequest struct {

+ 24 - 20
api/types/cluster.go

@@ -2,7 +2,6 @@ package types
 
 import (
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
-	v1 "k8s.io/api/core/v1"
 )
 
 const (
@@ -185,34 +184,39 @@ const (
 	AWSData          ClusterResolverName = "upload-aws-data"
 )
 
+// NamespaceResponse represents the response type of requests to the namespace resource
+//
 // swagger:model
-type ListNamespacesResponse struct {
-	*v1.NamespaceList
-}
-
-// swagger:model
-type CreateNamespaceRequest struct {
+type NamespaceResponse struct {
+	// the name of the namespace
+	// example: default
 	Name string `json:"name" form:"required"`
-}
 
-type CreateNamespaceResponseMeta struct {
-	Name string `json:"name,omitempty"`
-}
+	// the creation timestamp in UTC of the namespace in RFC 1123 format
+	// example: Mon, 13 Jun 2022 17:49:12 GMT
+	CreationTimestamp string `json:"creationTimestamp" form:"required"`
 
-// swagger:model
-type CreateNamespaceResponse struct {
-	Metadata CreateNamespaceResponseMeta `json:"metadata,omitempty"`
+	// the deletion timestamp in UTC of the namespace in RFC 1123 format, if the namespace is deleted
+	// example: Mon, 13 Jun 2022 17:49:12 GMT
+	DeletionTimestamp string `json:"deletionTimestamp,omitempty"`
+
+	// the status of the namespace
+	// enum: active,terminating
+	// example: active
+	Status string `json:"status" form:"required"`
 }
 
+// ListNamespacesResponse represents the list of all namespaces
+//
 // swagger:model
-type GetNamespaceResponse struct {
-	Metadata struct {
-		Name string `json:"name,omitempty"`
-	} `json:"metadata,omitempty"`
-}
+type ListNamespacesResponse []*NamespaceResponse
 
+// CreateNamespaceRequest represents the request body to create a namespace
+//
 // swagger:model
-type DeleteNamespaceRequest struct {
+type CreateNamespaceRequest struct {
+	// the name of the namespace to create
+	// example: sampleNS
 	Name string `json:"name" form:"required"`
 }
 

+ 28 - 9
api/types/git_action_config.go

@@ -1,6 +1,6 @@
 package types
 
-// GitActionConfig
+// The git configuration for this release (when deployed from a git repository)
 type GitActionConfig struct {
 	// The git repo in ${owner}/${repo} form
 	GitRepo string `json:"git_repo"`
@@ -24,15 +24,34 @@ type GitActionConfig struct {
 	FolderPath string `json:"folder_path"`
 }
 
+// The git configuration for this new release (when deploying from a git repository)
 type CreateGitActionConfigRequest struct {
-	GitRepo             string `json:"git_repo" form:"required"`
-	GitBranch           string `json:"git_branch"`
-	ImageRepoURI        string `json:"image_repo_uri" form:"required"`
-	DockerfilePath      string `json:"dockerfile_path"`
-	FolderPath          string `json:"folder_path"`
-	GitRepoID           uint   `json:"git_repo_id"`
-	GitlabIntegrationID uint   `json:"gitlab_integration_id"`
-	RegistryID          uint   `json:"registry_id"`
+	// The git repo in ${owner}/${repo} form
+	// required: true
+	GitRepo string `json:"git_repo" form:"required"`
+
+	// The branch name of the git repository
+	GitBranch string `json:"git_branch"`
+
+	// The complete image repository URI to pull from
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
+
+	// The path to the Dockerfile in the git repository
+	DockerfilePath string `json:"dockerfile_path"`
+
+	// The path to use as the base directory in the git repository
+	FolderPath string `json:"folder_path"`
+
+	// The Github installation ID with access to the repository
+	GitRepoID uint `json:"git_repo_id"`
+
+	// The Gitlab integration ID with access to the repository
+	GitlabIntegrationID uint `json:"gitlab_integration_id"`
+
+	// The Porter registry ID to link against
+	RegistryID uint `json:"registry_id"`
 
+	// Denotes if the Github workflow files need to be created
 	ShouldCreateWorkflow bool `json:"should_create_workflow"`
 }

+ 1 - 1
api/types/policy.go

@@ -17,8 +17,8 @@ const (
 	NamespaceScope         PermissionScope = "namespace"
 	SettingsScope          PermissionScope = "settings"
 	ReleaseScope           PermissionScope = "release"
-	GitlabIntegrationScope PermissionScope = "gitlab_integration"
 	StackScope             PermissionScope = "stack"
+	GitlabIntegrationScope PermissionScope = "gitlab_integration"
 )
 
 type NameOrUInt struct {

+ 82 - 10
api/types/registry.go

@@ -7,37 +7,58 @@ const (
 )
 
 type Registry struct {
+	// The ID of the registry
+	// minimum: 1
+	// example: 2
 	ID uint `json:"id"`
 
 	// The project that this integration belongs to
+	// minimum: 1
+	// example: 1
 	ProjectID uint `json:"project_id"`
 
 	// Name of the registry
+	// example: my-ecr-reg
 	Name string `json:"name"`
 
 	// URL of the registry
+	// example: 123456789.dkr.ecr.us-west-2.amazonaws.com
 	URL string `json:"url"`
 
 	// The integration service for this registry
-	Service RegistryService `json:"service"`
+	// enum: gcr,ecr,acr,docr,dockerhub
+	// example: ecr
+	Service string `json:"service"`
 
 	// The infra id, if registry was provisioned with Porter
+	// minimum: 1
+	// example: 2
 	InfraID uint `json:"infra_id"`
 
 	// The AWS integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 1
 	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
 
 	// The Azure integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 0
 	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
 
 	// The GCP integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 0
 	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
 
 	// The DO integration that was used to create or connect the registry:
 	// this points to an OAuthIntegrationID
+	// minimum: 1
+	// example: 0
 	DOIntegrationID uint `json:"do_integration_id,omitempty"`
 
-	// The basic integration that was used to connect the registry:
+	// The basic integration that was used to connect the registry.
+	// minimum: 1
+	// example: 0
 	BasicIntegrationID uint `json:"basic_integration_id,omitempty"`
 }
 
@@ -71,6 +92,7 @@ type Image struct {
 	PushedAt *time.Time `json:"pushed_at"`
 }
 
+// Type of registry service
 type RegistryService string
 
 const (
@@ -86,17 +108,47 @@ type RegistryListResponse []Registry
 
 // swagger:model
 type CreateRegistryRequest struct {
-	URL                string `json:"url"`
-	Name               string `json:"name" form:"required"`
-	GCPIntegrationID   uint   `json:"gcp_integration_id"`
-	AWSIntegrationID   uint   `json:"aws_integration_id"`
-	DOIntegrationID    uint   `json:"do_integration_id"`
-	BasicIntegrationID uint   `json:"basic_integration_id"`
-	AzureIntegrationID uint   `json:"azure_integration_id"`
+	// URL of the container registry
+	// example: 123456789.dkr.ecr.us-west-2.amazonaws.com
+	URL string `json:"url"`
+
+	// Name of the container registry
+	// required: true
+	// example: my-ecr-reg
+	Name string `json:"name" form:"required"`
+
+	// The GCP integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	GCPIntegrationID uint `json:"gcp_integration_id"`
+
+	// The AWS integration ID to be used for this registry
+	// minimum: 1
+	// example: 1
+	AWSIntegrationID uint `json:"aws_integration_id"`
+
+	// The DigitalOcean integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	DOIntegrationID uint `json:"do_integration_id"`
+
+	// The Basic integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	BasicIntegrationID uint `json:"basic_integration_id"`
+
+	// The Azure integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	AzureIntegrationID uint `json:"azure_integration_id"`
 
 	// Additional Azure-specific fields
+
+	// ACR resource group name (**Azure only**)
 	ACRResourceGroupName string `json:"acr_resource_group_name"`
-	ACRName              string `json:"acr_name"`
+
+	// ACR name (**Azure only**)
+	ACRName string `json:"acr_name"`
 }
 
 // swagger:model
@@ -107,6 +159,8 @@ type GetRegistryResponse Registry
 
 // swagger:model
 type CreateRegistryRepositoryRequest struct {
+	// The URL to the repository of a registry (**ECR only**)
+	// required: true
 	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 }
 
@@ -139,3 +193,21 @@ type ListRegistryRepositoryResponse []*RegistryRepository
 
 // swagger:model ListImagesResponse
 type ListImageResponse []*Image
+
+type V1ListImageRequest struct {
+	Num  int64  `schema:"num"`
+	Next string `schema:"next"`
+	Page uint   `schema:"page"`
+}
+
+// swagger:model V1ListImageResponse
+type V1ListImageResponse struct {
+	// The list of repository images with tags
+	Images []*Image `json:"images" form:"required"`
+
+	// The next page number used for pagination (**DigitalOcean only**)
+	NextPage uint `json:"next_page,omitempty"`
+
+	// The next page cursor used for pagination
+	Next string `json:"next,omitempty"`
+}

+ 62 - 16
api/types/release.go

@@ -15,14 +15,29 @@ type Release struct {
 }
 
 type PorterRelease struct {
-	ID              uint             `json:"id"`
-	WebhookToken    string           `json:"webhook_token"`
-	LatestVersion   string           `json:"latest_version"`
+	// The ID of this Porter release
+	ID uint `json:"id"`
+
+	// The webhook token used to secure Github repository webhooks
+	WebhookToken string `json:"webhook_token"`
+
+	// The latest version of this Porter release
+	LatestVersion string `json:"latest_version"`
+
+	// Configuration regarding the connected git repository
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
-	ImageRepoURI    string           `json:"image_repo_uri"`
-	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
-	Tags            []string         `json:"tags,omitempty"`
-	IsStack         bool             `json:"is_stack"`
+
+	// The complete image repository URI for this release
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The build configuration for this release when using buildpacks
+	BuildConfig *BuildConfig `json:"build_config,omitempty"`
+
+	// The list of tags for this release
+	Tags []string `json:"tags,omitempty"`
+
+	// Whether this release is tied to a stack or not
+	IsStack bool `json:"is_stack"`
 }
 
 // swagger:model
@@ -37,22 +52,44 @@ type UpdateNotificationConfigRequest struct {
 }
 
 type CreateReleaseBaseRequest struct {
-	RepoURL         string                 `json:"-" schema:"repo_url"`
-	TemplateName    string                 `json:"template_name" form:"required"`
-	TemplateVersion string                 `json:"template_version" form:"required"`
-	Values          map[string]interface{} `json:"values"`
-	Name            string                 `json:"name" form:"required"`
+	// The repository URL for this release
+	RepoURL string `json:"repo_url,omitempty" schema:"repo_url"`
+
+	// the Porter charts templated name
+	// required: true
+	TemplateName string `json:"template_name" form:"required"`
+
+	// The Porter charts template version
+	// required: true
+	TemplateVersion string `json:"template_version" form:"required"`
+
+	// The Helm values for this release
+	Values map[string]interface{} `json:"values"`
+
+	// The name of this release
+	// required: true
+	Name string `json:"name" form:"required"`
 }
 
 // swagger:model
 type CreateReleaseRequest struct {
 	*CreateReleaseBaseRequest
 
-	ImageURL        string                        `json:"image_url" form:"required"`
+	// The repository image URL for this release
+	// required: true
+	ImageURL string `json:"image_url" form:"required"`
+
+	// Configuration regarding the connected git repository
 	GitActionConfig *CreateGitActionConfigRequest `json:"git_action_config,omitempty"`
-	BuildConfig     *CreateBuildConfigRequest     `json:"build_config,omitempty"`
-	Tags            []string                      `json:"tags,omitempty"`
-	SyncedEnvGroups []string                      `json:"synced_env_groups,omitempty"`
+
+	// Build configuration options for this release
+	BuildConfig *CreateBuildConfigRequest `json:"build_config,omitempty"`
+
+	// The list of tags for this release
+	Tags []string `json:"tags,omitempty"`
+
+	// The list of synced environment groups for this release
+	SyncedEnvGroups []string `json:"synced_env_groups,omitempty"`
 }
 
 type CreateAddonRequest struct {
@@ -66,6 +103,15 @@ type RollbackReleaseRequest struct {
 }
 
 // swagger:model UpdateReleaseRequest
+type V1UpgradeReleaseRequest struct {
+	// The Helm values to upgrade the release with
+	// required: true
+	Values map[string]interface{} `json:"values" form:"required"`
+
+	// The Porter charts version to upgrade the release with
+	ChartVersion string `json:"version"`
+}
+
 type UpgradeReleaseRequest struct {
 	Values       string `json:"values" form:"required"`
 	ChartVersion string `json:"version"`

+ 1 - 1
api/types/user.go

@@ -66,5 +66,5 @@ type WelcomeWebhookRequest struct {
 	IsCompany bool   `json:"isCompany" schema:"isCompany"`
 	Company   string `json:"company" schema:"company"`
 	Role      string `json:"role" schema:"role"`
-	Name	  string `json:"name" schema:"name"`
+	Name      string `json:"name" schema:"name"`
 }

+ 5 - 3
cli/cmd/cluster.go

@@ -144,7 +144,7 @@ func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client
 	cID := cliConf.Cluster
 
 	// get the list of namespaces
-	namespaces, err := client.GetK8sNamespaces(
+	namespaceList, err := client.GetK8sNamespaces(
 		context.Background(),
 		pID,
 		cID,
@@ -159,8 +159,10 @@ func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client
 
 	fmt.Fprintf(w, "%s\t%s\n", "NAME", "STATUS")
 
-	for _, namespace := range namespaces.Items {
-		fmt.Fprintf(w, "%s\t%s\n", namespace.Name, namespace.Status.Phase)
+	namespaces := *namespaceList
+
+	for _, namespace := range namespaces {
+		fmt.Fprintf(w, "%s\t%s\n", namespace.Name, namespace.Status)
 	}
 
 	w.Flush()

+ 5 - 2
cli/cmd/get.go

@@ -65,10 +65,11 @@ func init() {
 }
 
 type getReleaseInfo struct {
-	Name         string
-	Namespace    string
+	Name         string    `json:"name" yaml:"name"`
+	Namespace    string    `json:"namespace" yaml:"namespace"`
 	LastDeployed time.Time `json:"last_deployed" yaml:"last_deployed"`
 	ReleaseType  string    `json:"release_type" yaml:"release_type"`
+	RevisionID   int       `json:"revision_id" yaml:"revision_id"`
 }
 
 func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
@@ -83,6 +84,7 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 		Namespace:    rel.Namespace,
 		LastDeployed: rel.Info.LastDeployed,
 		ReleaseType:  rel.Chart.Metadata.Name,
+		RevisionID:   rel.Release.Version,
 	}
 
 	if output == "yaml" {
@@ -106,6 +108,7 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 		fmt.Printf("Namespace:     %s\n", relInfo.Namespace)
 		fmt.Printf("Last deployed: %s\n", relInfo.LastDeployed)
 		fmt.Printf("Release type:  %s\n", relInfo.ReleaseType)
+		fmt.Printf("Revision ID:   %d\n", relInfo.RevisionID)
 	}
 
 	return nil

+ 6 - 6
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -50,18 +50,18 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           }
 
           let defaultNamespace = "default";
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           availableNamespaces.forEach(
-            (x: { metadata: { name: string } }, i: number) => {
+            (x: { name: string }, i: number) => {
               namespaceOptions.push({
-                label: x.metadata.name,
-                value: x.metadata.name,
+                label: x.name,
+                value: x.name,
               });
-              if (x.metadata.name === urlNamespace) {
+              if (x.name === urlNamespace) {
                 defaultNamespace = urlNamespace;
               }
             }

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -122,14 +122,14 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           const namespaceOptions = availableNamespaces.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
+            (x: { name: string }) => {
+              return { label: x.name, value: x.name };
             }
           );
           if (availableNamespaces.length > 0) {
@@ -340,7 +340,7 @@ const HeaderSection = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 20px;
+    font-size: 20px;
     color: #969Fbbaa;
     padding: 2px;
     border: 2px solid #969fbbaa;

+ 4 - 4
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -99,14 +99,14 @@ class SettingsPage extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           const namespaceOptions = availableNamespaces.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
+            (x: { name: string }) => {
+              return { label: x.name, value: x.name };
             }
           );
           if (availableNamespaces.length > 0) {

+ 2 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -26,10 +26,11 @@ const DeleteNamespaceModal = () => {
     api
       .deleteNamespace(
         "<token>",
-        { name: currentModalData?.metadata?.name },
+        {},
         {
           id: currentProject.id,
           cluster_id: currentCluster.id,
+          namespace: currentModalData?.metadata?.name,
         }
       )
       .then((res) => {

+ 4 - 5
dashboard/src/shared/api.tsx

@@ -1441,16 +1441,15 @@ const createNamespace = baseApi<
 });
 
 const deleteNamespace = baseApi<
-  {
-    name: string;
-  },
+  {},
   {
     id: number;
     cluster_id: number;
+    namespace: string;
   }
 >("DELETE", (pathParams) => {
-  let { id, cluster_id } = pathParams;
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/delete`;
+  let { id, cluster_id, namespace } = pathParams;
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}`;
 });
 
 const deleteJob = baseApi<

+ 1 - 1
internal/models/registry.go

@@ -66,7 +66,7 @@ func (r *Registry) ToRegistryType() *types.Registry {
 		ProjectID:          r.ProjectID,
 		Name:               r.Name,
 		URL:                uri,
-		Service:            serv,
+		Service:            string(serv),
 		InfraID:            r.InfraID,
 		GCPIntegrationID:   r.GCPIntegrationID,
 		AWSIntegrationID:   r.AWSIntegrationID,

+ 152 - 23
internal/registry/registry.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/url"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
@@ -664,6 +665,118 @@ func (r *Registry) ListImages(
 	return nil, fmt.Errorf("error listing images")
 }
 
+func (r *Registry) GetECRPaginatedImages(
+	repoName string,
+	repo repository.Repository,
+	maxResults int64,
+	nextToken *string,
+) ([]*ptypes.Image, *string, error) {
+	aws, err := repo.AWSIntegration().ReadAWSIntegration(
+		r.ProjectID,
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	svc := ecr.New(sess)
+
+	resp, err := svc.ListImages(&ecr.ListImagesInput{
+		RepositoryName: &repoName,
+		MaxResults:     &maxResults,
+		NextToken:      nextToken,
+	})
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if len(resp.ImageIds) == 0 {
+		return []*ptypes.Image{}, nil, nil
+	}
+
+	imageIDLen := len(resp.ImageIds)
+	imageDetails := make([]*ecr.ImageDetail, 0)
+	imageIDMap := make(map[string]bool)
+
+	for _, id := range resp.ImageIds {
+		imageIDMap[*id.ImageTag] = true
+	}
+
+	var wg sync.WaitGroup
+	var mu sync.Mutex
+
+	// AWS API expects the length of imageIDs to be at max 100 at a time
+	for start := 0; start < imageIDLen; start += 100 {
+		end := start + 100
+		if end > imageIDLen {
+			end = imageIDLen
+		}
+
+		wg.Add(1)
+
+		go func(start, end int) {
+			defer wg.Done()
+
+			describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+				RepositoryName: &repoName,
+				ImageIds:       resp.ImageIds[start:end],
+			})
+
+			if err != nil {
+				return
+			}
+
+			mu.Lock()
+			imageDetails = append(imageDetails, describeResp.ImageDetails...)
+			mu.Unlock()
+		}(start, end)
+	}
+
+	wg.Wait()
+
+	res := make([]*ptypes.Image, 0)
+	imageInfoMap := make(map[string]*ptypes.Image)
+
+	for _, img := range imageDetails {
+		for _, tag := range img.ImageTags {
+			newImage := &ptypes.Image{
+				Digest:         *img.ImageDigest,
+				Tag:            *tag,
+				RepositoryName: repoName,
+				PushedAt:       img.ImagePushedAt,
+			}
+
+			if _, ok := imageIDMap[*tag]; ok {
+				if _, ok := imageInfoMap[*tag]; !ok {
+					imageInfoMap[*tag] = newImage
+				}
+			}
+
+			if len(imageInfoMap) == int(maxResults) {
+				break
+			}
+		}
+
+		if len(imageInfoMap) == int(maxResults) {
+			break
+		}
+	}
+
+	for _, v := range imageInfoMap {
+		res = append(res, v)
+	}
+
+	return res, resp.NextToken, nil
+}
+
 func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([]*ptypes.Image, error) {
 	aws, err := repo.AWSIntegration().ReadAWSIntegration(
 		r.ProjectID,
@@ -707,6 +820,7 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		resp, err := svc.ListImages(&ecr.ListImagesInput{
 			RepositoryName: &repoName,
 			MaxResults:     &maxResults,
+			NextToken:      nextToken,
 		})
 
 		if err != nil {
@@ -717,48 +831,63 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		nextToken = resp.NextToken
 	}
 
-	describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
-		RepositoryName: &repoName,
-		MaxResults:     &maxResults,
-		ImageIds:       imageIDs,
-	})
+	imageIDLen := len(imageIDs)
+	imageDetails := make([]*ecr.ImageDetail, 0)
 
-	if err != nil {
-		return nil, err
-	}
+	var wg sync.WaitGroup
+	var mu sync.Mutex
 
-	imageDetails := describeResp.ImageDetails
+	// AWS API expects the length of imageIDs to be at max 100 at a time
+	for start := 0; start < imageIDLen; start += 100 {
+		end := start + 100
+		if end > imageIDLen {
+			end = imageIDLen
+		}
 
-	nextToken = describeResp.NextToken
+		wg.Add(1)
 
-	for nextToken != nil {
-		describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
-			RepositoryName: &repoName,
-			MaxResults:     &maxResults,
-			ImageIds:       resp.ImageIds,
-		})
+		go func(start, end int) {
+			defer wg.Done()
 
-		if err != nil {
-			return nil, err
-		}
+			describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+				RepositoryName: &repoName,
+				ImageIds:       imageIDs[start:end],
+			})
+
+			if err != nil {
+				return
+			}
 
-		nextToken = describeResp.NextToken
-		imageDetails = append(imageDetails, describeResp.ImageDetails...)
+			mu.Lock()
+			imageDetails = append(imageDetails, describeResp.ImageDetails...)
+			mu.Unlock()
+		}(start, end)
 	}
 
+	wg.Wait()
+
 	res := make([]*ptypes.Image, 0)
+	imageInfoMap := make(map[string]*ptypes.Image)
 
 	for _, img := range imageDetails {
 		for _, tag := range img.ImageTags {
-			res = append(res, &ptypes.Image{
+			newImage := &ptypes.Image{
 				Digest:         *img.ImageDigest,
 				Tag:            *tag,
 				RepositoryName: repoName,
 				PushedAt:       img.ImagePushedAt,
-			})
+			}
+
+			if _, ok := imageInfoMap[*tag]; !ok {
+				imageInfoMap[*tag] = newImage
+			}
 		}
 	}
 
+	for _, v := range imageInfoMap {
+		res = append(res, v)
+	}
+
 	return res, nil
 }