Explorar el Código

Merge branch 'master' of github.com:porter-dev/porter into nico/por-521-frontend-stacks-work

jnfrati hace 3 años
padre
commit
eb35ccea6e
Se han modificado 45 ficheros con 1450 adiciones y 294 borrados
  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. 20 3
      api/server/handlers/environment/create.go
  7. 26 16
      api/server/handlers/environment/delete.go
  8. 1 1
      api/server/handlers/handler.go
  9. 1 1
      api/server/handlers/project_oauth/digitalocean.go
  10. 1 1
      api/server/handlers/project_oauth/slack.go
  11. 112 3
      api/server/handlers/registry/create.go
  12. 2 0
      api/server/handlers/registry/create_repository.go
  13. 29 20
      api/server/handlers/release/create.go
  14. 1 1
      api/server/handlers/release/delete.go
  15. 3 3
      api/server/handlers/release/update_rollback.go
  16. 2 2
      api/server/handlers/release/upgrade.go
  17. 94 0
      api/server/handlers/v1/registry/list_images.go
  18. 235 0
      api/server/handlers/v1/release/upgrade.go
  19. 2 2
      api/server/router/cluster.go
  20. 2 2
      api/server/router/router.go
  21. 13 6
      api/server/router/v1/cluster.go
  22. 42 10
      api/server/router/v1/registry.go
  23. 26 7
      api/server/router/v1/release.go
  24. 11 4
      api/types/build_config.go
  25. 24 20
      api/types/cluster.go
  26. 28 9
      api/types/git_action_config.go
  27. 1 1
      api/types/policy.go
  28. 82 10
      api/types/registry.go
  29. 62 16
      api/types/release.go
  30. 1 0
      api/types/user.go
  31. 5 3
      cli/cmd/cluster.go
  32. 5 2
      cli/cmd/get.go
  33. 60 64
      dashboard/src/main/home/WelcomeForm.tsx
  34. 6 6
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  35. 5 5
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  36. 48 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  37. 4 4
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  38. 2 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  39. 5 5
      dashboard/src/shared/api.tsx
  40. 62 2
      internal/integrations/ci/actions/actions.go
  41. 117 24
      internal/integrations/ci/actions/preview.go
  42. 1 1
      internal/models/registry.go
  43. 193 18
      internal/registry/registry.go
  44. 6 0
      internal/repository/test/repository.go
  45. 56 0
      internal/repository/test/stack.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)

+ 20 - 3
api/server/handlers/environment/create.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -134,8 +135,18 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	})
 
 	if err != nil {
-		c.deleteEnvAndReportError(w, r, env, err)
-		return
+		unwrappedErr := errors.Unwrap(err)
+
+		if unwrappedErr != nil {
+			if errors.Is(unwrappedErr, actions.ErrProtectedBranch) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+			} else if errors.Is(unwrappedErr, actions.ErrCreatePRForProtectedBranch) {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
+			}
+		} else {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	c.WriteResult(w, r, env.ToEnvironmentType())
@@ -144,7 +155,13 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 func (c *CreateEnvironmentHandler) deleteEnvAndReportError(
 	w http.ResponseWriter, r *http.Request, env *models.Environment, err error,
 ) {
-	c.Repo().Environment().DeleteEnvironment(env)
+	_, delErr := c.Repo().Environment().DeleteEnvironment(env)
+
+	if delErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(delErr))
+		return
+	}
+
 	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 }
 

+ 26 - 16
api/server/handlers/environment/delete.go

@@ -1,6 +1,8 @@
 package environment
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/authz"
@@ -58,22 +60,6 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	err = actions.DeleteEnv(&actions.EnvOpts{
-		Client:            client,
-		ServerURL:         c.Config().ServerConf.ServerURL,
-		GitRepoOwner:      env.GitRepoOwner,
-		GitRepoName:       env.GitRepoName,
-		ProjectID:         project.ID,
-		ClusterID:         cluster.ID,
-		GitInstallationID: uint(ga.InstallationID),
-		EnvironmentName:   env.Name,
-	})
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	// delete all corresponding deployments
 	agent, err := c.GetAgent(r, cluster, "")
 
@@ -101,5 +87,29 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	err = actions.DeleteEnv(&actions.EnvOpts{
+		Client:            client,
+		ServerURL:         c.Config().ServerConf.ServerURL,
+		GitRepoOwner:      env.GitRepoOwner,
+		GitRepoName:       env.GitRepoName,
+		ProjectID:         project.ID,
+		ClusterID:         cluster.ID,
+		GitInstallationID: uint(ga.InstallationID),
+		EnvironmentName:   env.Name,
+	})
+
+	if err != nil {
+		if errors.Is(err, actions.ErrProtectedBranch) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("We were unable to delete the Porter Preview Environment workflow files for this "+
+					"repository as the default branch is protected. Please manually delete them."), http.StatusConflict,
+			))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	c.WriteResult(w, r, env.ToEnvironmentType())
 }

+ 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)
 }

+ 29 - 20
api/server/handlers/release/create.go

@@ -2,6 +2,7 @@ package release
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -163,6 +164,15 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
+	if request.BuildConfig != nil {
+		_, err = createBuildConfig(c.Config(), release, request.BuildConfig)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	if request.GitActionConfig != nil {
 		_, _, err := createGitAction(
 			c.Config(),
@@ -176,20 +186,21 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
+			unwrappedErr := errors.Unwrap(err)
+
+			if unwrappedErr != nil {
+				if errors.Is(unwrappedErr, actions.ErrProtectedBranch) {
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+				} else if errors.Is(unwrappedErr, actions.ErrCreatePRForProtectedBranch) {
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
+				}
+			} else {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
 		}
 	}
 
-	if request.BuildConfig != nil {
-		_, err = createBuildConfig(c.Config(), release, request.BuildConfig)
-	}
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	c.Config().AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
 		&analytics.ApplicationLaunchSuccessTrackOpts{
 			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
@@ -203,6 +214,8 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			FlowID: operationID,
 		},
 	))
+
+	w.WriteHeader(http.StatusCreated)
 }
 
 func CreateAppReleaseFromHelmRelease(
@@ -361,11 +374,11 @@ func createGitAction(
 		// need to call Setup() in order to get the workflow file before writing the
 		// action config, in the case of a dry run, since the dry run does not create
 		// a git action config.
-		workflowYAML, githubErr := gaRunner.Setup()
+		workflowYAML, gitErr = gaRunner.Setup()
 
 		if gaRunner.DryRun {
-			if githubErr != nil {
-				return nil, nil, githubErr
+			if gitErr != nil {
+				return nil, nil, gitErr
 			}
 
 			return nil, workflowYAML, nil
@@ -399,11 +412,7 @@ func createGitAction(
 		return nil, nil, err
 	}
 
-	if gitErr != nil {
-		return nil, nil, gitErr
-	}
-
-	return ga.ToGitActionConfigType(), workflowYAML, nil
+	return ga.ToGitActionConfigType(), workflowYAML, gitErr
 }
 
 func getToken(
@@ -547,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 - 0
api/types/user.go

@@ -66,4 +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"`
 }

+ 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

+ 60 - 64
dashboard/src/main/home/WelcomeForm.tsx

@@ -16,9 +16,10 @@ type StateType = {
 const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
   const context = useContext(Context);
   const [active, setActive] = useState(true);
-  const [isCompany, setIsCompany] = useState(false);
-  const [role, setRole] = useState("unspecified");
+  const [isCompany, setIsCompany] = useState(true);
+  const [name, setName] = useState("");
   const [company, setCompany] = useState("");
+  const [role, setRole] = useState("unspecified");
 
   const submitForm = () => {
     api
@@ -26,6 +27,7 @@ const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
         "<token>",
         {
           email: context.user && context.user.email,
+          name,
           isCompany,
           company,
           role,
@@ -40,70 +42,64 @@ const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
   };
 
   const renderContents = () => {
-    if (isCompany) {
-      return (
-        <FadeWrapper>
-          <Title>Welcome to Porter</Title>
-          <Subtitle>Just two things before getting started.</Subtitle>
-          <SubtitleAlt>
-            <Num>1</Num> What is your company website? *
-          </SubtitleAlt>
-          <Input
-            placeholder="ex: https://porter.run"
-            value={company}
-            onChange={(e: any) => setCompany(e.target.value)}
-          />
-          <SubtitleAlt>
-            <Num>2</Num> What is your role? *
-          </SubtitleAlt>
-          <RadioButton
-            onClick={() => setRole("founder")}
-            selected={role === "founder"}
-          >
-            <i className="material-icons-round">
-              {role === "founder" ? "check_box" : "check_box_outline_blank"}
-            </i>{" "}
-            Founder
-          </RadioButton>
-          <RadioButton
-            onClick={() => setRole("developer")}
-            selected={role === "developer"}
-          >
-            <i className="material-icons-round">
-              {role === "developer" ? "check_box" : "check_box_outline_blank"}
-            </i>{" "}
-            Developer
-          </RadioButton>
-          <RadioButton
-            onClick={() => setRole("devops")}
-            selected={role === "devops"}
-          >
-            <i className="material-icons-round">
-              {role === "devops" ? "check_box" : "check_box_outline_blank"}
-            </i>{" "}
-            DevOps
-          </RadioButton>
-
-          <Submit
-            isDisabled={!company || role === "unspecified"}
-            onClick={() => company && role !== "unspecified" && submitForm()}
-          >
-            <i className="material-icons-round">check</i> Done
-          </Submit>
-        </FadeWrapper>
-      );
-    }
     return (
-      <>
+      <FadeWrapper>
         <Title>Welcome to Porter</Title>
-        <Subtitle delay="0.7s">I am interested in using Porter as:</Subtitle>
-        <Option onClick={() => setIsCompany(true)}>
-          <i className="material-icons-round">people</i> A Company
-        </Option>
-        <Option onClick={() => submitForm()}>
-          <i className="material-icons-round">person</i> An Individual
-        </Option>
-      </>
+        <Subtitle>Just a few things before getting started.</Subtitle>
+        <SubtitleAlt>
+          <Num>1</Num> What is your name? *
+        </SubtitleAlt>
+        <Input
+          placeholder="John Doe"
+          value={name}
+          onChange={(e: any) => setName(e.target.value)}
+        />
+        <SubtitleAlt>
+          <Num>2</Num> What is your company website? *
+        </SubtitleAlt>
+        <Input
+          placeholder="ex: https://porter.run"
+          value={company}
+          onChange={(e: any) => setCompany(e.target.value)}
+        />
+        <SubtitleAlt>
+          <Num>3</Num> What is your role? *
+        </SubtitleAlt>
+        <RadioButton
+          onClick={() => setRole("founder")}
+          selected={role === "founder"}
+        >
+          <i className="material-icons-round">
+            {role === "founder" ? "check_box" : "check_box_outline_blank"}
+          </i>{" "}
+          Founder
+        </RadioButton>
+        <RadioButton
+          onClick={() => setRole("developer")}
+          selected={role === "developer"}
+        >
+          <i className="material-icons-round">
+            {role === "developer" ? "check_box" : "check_box_outline_blank"}
+          </i>{" "}
+          Developer
+        </RadioButton>
+        <RadioButton
+          onClick={() => setRole("devops")}
+          selected={role === "devops"}
+        >
+          <i className="material-icons-round">
+            {role === "devops" ? "check_box" : "check_box_outline_blank"}
+          </i>{" "}
+          DevOps
+        </RadioButton>
+
+        <Submit
+          isDisabled={!company || role === "unspecified"}
+          onClick={() => company && role !== "unspecified" && submitForm()}
+        >
+          <i className="material-icons-round">check</i> Done
+        </Submit>
+      </FadeWrapper>
     );
   };
 

+ 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;

+ 48 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -25,12 +25,58 @@ const readableDate = (s: string) => {
   return `${time} on ${date}`;
 };
 
-const renderStatus = (job: any, time: string) => {
+const getLatestPod = (pods: any[]) => {
+  if (!Array.isArray(pods)) {
+    return undefined;
+  }
+
+  return [...pods]
+    .sort((a: any, b: any) => {
+      if (!a?.metadata?.creationTimestamp) {
+        return 1;
+      }
+
+      if (!b?.metadata?.creationTimestamp) {
+        return -1;
+      }
+
+      return (
+        new Date(b?.metadata?.creationTimestamp).getTime() -
+        new Date(a?.metadata?.creationTimestamp).getTime()
+      );
+    })
+    .shift();
+};
+
+const renderStatus = (job: any, pods: any[], time: string) => {
   if (job.status?.succeeded >= 1) {
     return <Status color="#38a88a">Succeeded {time}</Status>;
   }
 
   if (job.status?.failed >= 1) {
+    const appPod = getLatestPod(pods);
+
+    if (appPod) {
+      const appContainerStatus = appPod?.status?.containerStatuses?.find(
+        (container: any) =>
+          container?.state?.terminated?.reason !== "Completed" &&
+          !container?.state?.running
+      );
+
+      if (appContainerStatus) {
+        const reason = appContainerStatus.state.terminated.reason;
+        const exitCode = appContainerStatus.state.terminated.exitCode;
+        const finishTime = appContainerStatus.state.terminated.finishedAt;
+
+        return (
+          <Status color="#cc3d42">
+            Failed at {time ? time : readableDate(finishTime)} - Reason:{" "}
+            {reason} - Exit Code: {exitCode}
+          </Status>
+        );
+      }
+    }
+
     return (
       <Status color="#cc3d42">
         Failed {time}
@@ -163,6 +209,7 @@ const ExpandedJobRun = ({
           <LastDeployed>
             {renderStatus(
               run,
+              pods,
               run.status.completionTime
                 ? readableDate(run.status.completionTime)
                 : ""

+ 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) => {

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

@@ -1189,6 +1189,7 @@ const getMetadata = baseApi<{}, {}>("GET", () => {
 const postWelcome = baseApi<{
   email: string;
   isCompany: boolean;
+  name: string;
   company: string;
   role: string;
 }>("POST", () => {
@@ -1440,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<

+ 62 - 2
internal/integrations/ci/actions/actions.go

@@ -3,6 +3,7 @@ package actions
 import (
 	"context"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -20,6 +21,11 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
+var (
+	ErrProtectedBranch            = errors.New("protected branch")
+	ErrCreatePRForProtectedBranch = errors.New("unable to create PR to merge workflow files into protected branch")
+)
+
 type GithubActions struct {
 	ServerURL    string
 	InstanceName string
@@ -97,9 +103,63 @@ func (g *GithubActions) Setup() ([]byte, error) {
 			branch = g.defaultBranch
 		}
 
+		// check if the branch is protected
+		githubBranch, _, err := client.Repositories.GetBranch(
+			context.Background(),
+			g.GitRepoOwner,
+			g.GitRepoName,
+			branch,
+			true,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
 		isOAuth := g.GithubOAuthIntegration != nil
 
-		_, err = commitGithubFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner, g.GitRepoName, branch, isOAuth)
+		if githubBranch.GetProtected() {
+			err = createNewBranch(client, g.GitRepoOwner, g.GitRepoName, branch, "porter-setup")
+
+			if err != nil {
+				return nil, fmt.Errorf(
+					"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+						"To enable automatic deployments to Porter, please create a Github workflow "+
+						"file in this branch with the following contents:\n"+
+						"--------\n%s--------\nERROR: %w", branch, string(workflowYAML), ErrCreatePRForProtectedBranch,
+				)
+			}
+
+			_, err = commitWorkflowFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner,
+				g.GitRepoName, "porter-setup", isOAuth)
+
+			if err != nil {
+				return nil, fmt.Errorf(
+					"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+						"To enable automatic deployments to Porter, please create a Github workflow "+
+						"file in this branch with the following contents:\n"+
+						"--------\n%s--------\nERROR: %w", branch, string(workflowYAML), ErrCreatePRForProtectedBranch,
+				)
+			}
+
+			pr, _, err := client.PullRequests.Create(
+				context.Background(), g.GitRepoOwner, g.GitRepoName, &github.NewPullRequest{
+					Title: github.String("Enable Porter automatic deployments"),
+					Base:  github.String(branch),
+					Head:  github.String("porter-setup"),
+				},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			return nil, fmt.Errorf("Please merge %s to enable automatic deployments on Porter.\nERROR: %w",
+				pr.GetHTMLURL(), ErrProtectedBranch)
+		}
+
+		_, err = commitWorkflowFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner,
+			g.GitRepoName, branch, isOAuth)
 		if err != nil {
 			return workflowYAML, err
 		}
@@ -382,7 +442,7 @@ func getPorterTokenSecretName(projectID uint) string {
 	return fmt.Sprintf("PORTER_TOKEN_%d", projectID)
 }
 
-func commitGithubFile(
+func commitWorkflowFile(
 	client *github.Client,
 	filename string,
 	contents []byte,

+ 117 - 24
internal/integrations/ci/actions/preview.go

@@ -79,7 +79,78 @@ func SetupEnv(opts *EnvOpts) error {
 		return err
 	}
 
-	_, err = commitGithubFile(
+	githubBranch, _, err := opts.Client.Repositories.GetBranch(
+		context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, true,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if githubBranch.GetProtected() {
+		err = createNewBranch(opts.Client, opts.GitRepoOwner, opts.GitRepoName, defaultBranch, "porter-preview")
+
+		if err != nil {
+			return fmt.Errorf(
+				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+					"To enable Porter Preview Environment deployments, please create Github workflow "+
+					"files in this branch with the following contents:\n"+
+					"--------\n%s--------\n--------\n%s--------\nERROR: %w",
+				defaultBranch, string(applyWorkflowYAML), string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+			)
+		}
+
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
+			applyWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, "porter-preview", false,
+		)
+
+		if err != nil {
+			return fmt.Errorf(
+				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+					"To enable Porter Preview Environment deployments, please create Github workflow "+
+					"files in this branch with the following contents:\n"+
+					"--------\n%s--------\n--------\n%s--------\nERROR: %w",
+				defaultBranch, string(applyWorkflowYAML), string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+			)
+		}
+
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
+			deleteWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, "porter-preview", false,
+		)
+
+		if err != nil {
+			return fmt.Errorf(
+				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
+					"To enable Porter Preview Environment deployments, please create a Github workflow "+
+					"file in this branch with the following contents:\n"+
+					"--------\n%s--------\nERROR: %w",
+				defaultBranch, string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+			)
+		}
+
+		pr, _, err := opts.Client.PullRequests.Create(
+			context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
+				Title: github.String("Enable Porter Preview Environment deployments"),
+				Base:  github.String(defaultBranch),
+				Head:  github.String("porter-preview"),
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+
+		return fmt.Errorf("Please merge %s to enable Porter Preview Environment deployments.\nERROR: %w",
+			pr.GetHTMLURL(), ErrProtectedBranch)
+	}
+
+	_, err = commitWorkflowFile(
 		opts.Client,
 		fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
 		applyWorkflowYAML,
@@ -92,13 +163,13 @@ func SetupEnv(opts *EnvOpts) error {
 	if err != nil {
 		if strings.Contains(err.Error(), "409 Could not create file") {
 			// possibly a write-protected branch
-			err = createPorterPreviewBranch(opts, defaultBranch)
+			err = createNewBranch(opts.Client, opts.GitRepoOwner, opts.GitRepoName, defaultBranch, "porter-preview")
 
 			if err != nil {
 				return fmt.Errorf("write-protected branch %s. Error creating porter-preview branch: %w", defaultBranch, err)
 			}
 
-			_, err = commitGithubFile(
+			_, err = commitWorkflowFile(
 				opts.Client,
 				fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
 				applyWorkflowYAML,
@@ -112,7 +183,7 @@ func SetupEnv(opts *EnvOpts) error {
 				return fmt.Errorf("write-protected branch %s. Error committing to porter-preview branch: %w", defaultBranch, err)
 			}
 
-			_, err = commitGithubFile(
+			_, err = commitWorkflowFile(
 				opts.Client,
 				fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
 				deleteWorkflowYAML,
@@ -144,7 +215,7 @@ func SetupEnv(opts *EnvOpts) error {
 		return err
 	}
 
-	_, err = commitGithubFile(
+	_, err = commitWorkflowFile(
 		opts.Client,
 		fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
 		deleteWorkflowYAML,
@@ -197,6 +268,18 @@ func DeleteEnv(opts *EnvOpts) error {
 		}
 	}
 
+	githubBranch, _, err := opts.Client.Repositories.GetBranch(
+		context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, true,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if githubBranch.GetProtected() {
+		return ErrProtectedBranch
+	}
+
 	err = deleteGithubFile(
 		opts.Client,
 		fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
@@ -310,36 +393,46 @@ func getPreviewDeleteActionYAML(opts *EnvOpts) ([]byte, error) {
 	return yaml.Marshal(actionYAML)
 }
 
-func createPorterPreviewBranch(opts *EnvOpts, defaultBranch string) error {
-	_, resp, err := opts.Client.Repositories.GetBranch(
-		context.Background(), opts.GitRepoOwner, opts.GitRepoName, "porter-preview", false,
+func createNewBranch(
+	client *github.Client,
+	gitRepoOwner, gitRepoName, baseBranch, headBranch string,
+) error {
+	_, resp, err := client.Repositories.GetBranch(
+		context.Background(), gitRepoOwner, gitRepoName, headBranch, true,
 	)
 
-	if resp.StatusCode == http.StatusNotFound {
-		branch, _, err := opts.Client.Repositories.GetBranch(
-			context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, false,
-		)
-
-		if err != nil {
-			return err
-		}
+	headBranchRef := fmt.Sprintf("refs/heads/%s", headBranch)
 
-		_, _, err = opts.Client.Git.CreateRef(
-			context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.Reference{
-				Ref: github.String("refs/heads/porter-preview"),
-				Object: &github.GitObject{
-					SHA: branch.Commit.SHA,
-				},
-			},
+	if err == nil {
+		// delete the stale branch
+		_, err := client.Git.DeleteRef(
+			context.Background(), gitRepoOwner, gitRepoName, headBranchRef,
 		)
 
 		if err != nil {
 			return err
 		}
+	} else if resp.StatusCode != http.StatusNotFound {
+		return err
+	}
+
+	base, _, err := client.Repositories.GetBranch(
+		context.Background(), gitRepoOwner, gitRepoName, baseBranch, true,
+	)
 
-		return nil
+	if err != nil {
+		return err
 	}
 
+	_, _, err = client.Git.CreateRef(
+		context.Background(), gitRepoOwner, gitRepoName, &github.Reference{
+			Ref: github.String(headBranchRef),
+			Object: &github.GitObject{
+				SHA: base.Commit.SHA,
+			},
+		},
+	)
+
 	if err != nil {
 		return err
 	}

+ 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,

+ 193 - 18
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,76 +665,229 @@ func (r *Registry) ListImages(
 	return nil, fmt.Errorf("error listing images")
 }
 
-func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([]*ptypes.Image, error) {
+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, err
+		return nil, nil, err
 	}
 
 	sess, err := aws.GetSession()
 
 	if err != nil {
-		return nil, err
+		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, err
+		return nil, nil, err
 	}
 
 	if len(resp.ImageIds) == 0 {
-		return []*ptypes.Image{}, nil
+		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,
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
 	}
 
-	describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, err
+	}
+
+	svc := ecr.New(sess)
+
+	maxResults := int64(1000)
+
+	var imageIDs []*ecr.ImageIdentifier
+
+	resp, err := svc.ListImages(&ecr.ListImagesInput{
 		RepositoryName: &repoName,
-		ImageIds:       resp.ImageIds,
+		MaxResults:     &maxResults,
 	})
 
 	if err != nil {
 		return nil, err
 	}
 
-	imageDetails := describeResp.ImageDetails
+	if len(resp.ImageIds) == 0 {
+		return []*ptypes.Image{}, nil
+	}
+
+	imageIDs = append(imageIDs, resp.ImageIds...)
 
-	nextToken := describeResp.NextToken
+	nextToken := resp.NextToken
 
 	for nextToken != nil {
-		describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+		resp, err := svc.ListImages(&ecr.ListImagesInput{
 			RepositoryName: &repoName,
-			ImageIds:       resp.ImageIds,
+			MaxResults:     &maxResults,
+			NextToken:      nextToken,
 		})
 
 		if err != nil {
 			return nil, err
 		}
 
-		nextToken = describeResp.NextToken
-		imageDetails = append(imageDetails, describeResp.ImageDetails...)
+		imageIDs = append(imageIDs, resp.ImageIds...)
+		nextToken = resp.NextToken
+	}
+
+	imageIDLen := len(imageIDs)
+	imageDetails := make([]*ecr.ImageDetail, 0)
+
+	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:       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 {
-			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
 }
 
@@ -877,10 +1031,31 @@ func (r *Registry) listDOCRImages(
 
 	name := urlArr[1]
 
-	tags, _, err := client.Registry.ListRepositoryTags(context.TODO(), name, repoName, &godo.ListOptions{})
+	var tags []*godo.RepositoryTag
+	opt := &godo.ListOptions{
+		PerPage: 200,
+	}
 
-	if err != nil {
-		return nil, err
+	for {
+		nextTags, resp, err := client.Registry.ListRepositoryTags(context.TODO(), name, repoName, opt)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tags = append(tags, nextTags...)
+
+		if resp.Links == nil || resp.Links.IsLastPage() {
+			break
+		}
+
+		page, err := resp.Links.CurrentPage()
+
+		if err != nil {
+			return nil, err
+		}
+
+		opt.Page = page + 1
 	}
 
 	res := make([]*ptypes.Image, 0)

+ 6 - 0
internal/repository/test/repository.go

@@ -45,6 +45,7 @@ type TestRepository struct {
 	apiToken                  repository.APITokenRepository
 	policy                    repository.PolicyRepository
 	tag                       repository.TagRepository
+	stack                     repository.StackRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -207,6 +208,10 @@ func (t *TestRepository) Tag() repository.TagRepository {
 	return t.tag
 }
 
+func (t *TestRepository) Stack() repository.StackRepository {
+	return t.stack
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -251,5 +256,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		apiToken:                  NewAPITokenRepository(canQuery),
 		policy:                    NewPolicyRepository(canQuery),
 		tag:                       NewTagRepository(),
+		stack:                     NewStackRepository(),
 	}
 }

+ 56 - 0
internal/repository/test/stack.go

@@ -0,0 +1,56 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type StackRepository struct{}
+
+func NewStackRepository() repository.StackRepository {
+	return &StackRepository{}
+}
+
+// CreateStack creates a new stack
+func (repo *StackRepository) CreateStack(stack *models.Stack) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace string) ([]*models.Stack, error) {
+	panic("unimplemented")
+}
+
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
+// DeleteStack creates a new stack
+func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) AppendNewRevision(revision *models.StackRevision) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) ReadStackResource(resourceID uint) (*models.StackResource, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) UpdateStackResource(resource *models.StackResource) (*models.StackResource, error) {
+	panic("unimplemented")
+}