Browse Source

Merge branch 'master' of github.com:porter-dev/porter into stacks-v2-notifications

Feroze Mohideen 2 năm trước cách đây
mục cha
commit
2c998ef5dd

+ 59 - 12
api/client/porter_app.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/internal/models"
+	appInternal "github.com/porter-dev/porter/internal/porter_app"
 
 	"github.com/porter-dev/porter/api/types"
 )
@@ -257,6 +258,50 @@ func (c *Client) ApplyPorterApp(
 	return resp, err
 }
 
+// UpdateAppInput is the input struct to UpdateApp
+type UpdateAppInput struct {
+	ProjectID          uint
+	ClusterID          uint
+	Name               string
+	GitSource          porter_app.GitSource
+	DeploymentTargetId string
+	CommitSHA          string
+	AppRevisionID      string
+	Base64AppProto     string
+	Base64PorterYAML   string
+	IsEnvOverride      bool
+}
+
+// UpdateApp updates a porter app
+func (c *Client) UpdateApp(
+	ctx context.Context,
+	inp UpdateAppInput,
+) (*porter_app.UpdateAppResponse, error) {
+	resp := &porter_app.UpdateAppResponse{}
+
+	req := &porter_app.UpdateAppRequest{
+		Name:               inp.Name,
+		GitSource:          inp.GitSource,
+		DeploymentTargetId: inp.DeploymentTargetId,
+		CommitSHA:          inp.CommitSHA,
+		AppRevisionID:      inp.AppRevisionID,
+		Base64AppProto:     inp.Base64AppProto,
+		Base64PorterYAML:   inp.Base64PorterYAML,
+		IsEnvOverride:      inp.IsEnvOverride,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/update",
+			inp.ProjectID, inp.ClusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 // DefaultDeploymentTarget returns the default deployment target for a given project and cluster
 func (c *Client) DefaultDeploymentTarget(
 	ctx context.Context,
@@ -321,30 +366,32 @@ func (c *Client) CreatePorterAppDBEntry(
 	projectID uint, clusterID uint,
 	inp CreatePorterAppDBEntryInput,
 ) error {
-	var sourceType porter_app.SourceType
-	var image *porter_app.Image
+	var sourceType appInternal.SourceType
+	var image *appInternal.Image
 	if inp.Local {
-		sourceType = porter_app.SourceType_Local
+		sourceType = appInternal.SourceType_Local
 	}
 	if inp.GitRepoName != "" {
-		sourceType = porter_app.SourceType_Github
+		sourceType = appInternal.SourceType_Github
 	}
 	if inp.ImageRepository != "" {
-		sourceType = porter_app.SourceType_DockerRegistry
-		image = &porter_app.Image{
+		sourceType = appInternal.SourceType_DockerRegistry
+		image = &appInternal.Image{
 			Repository: inp.ImageRepository,
 			Tag:        inp.ImageTag,
 		}
 	}
 
 	req := &porter_app.CreateAppRequest{
-		Name:               inp.AppName,
-		SourceType:         sourceType,
-		GitBranch:          inp.GitBranch,
-		GitRepoName:        inp.GitRepoName,
-		GitRepoID:          inp.GitRepoID,
-		PorterYamlPath:     inp.PorterYamlPath,
+		Name:       inp.AppName,
+		SourceType: sourceType,
+		GitSource: porter_app.GitSource{
+			GitBranch:   inp.GitBranch,
+			GitRepoName: inp.GitRepoName,
+			GitRepoID:   inp.GitRepoID,
+		},
 		Image:              image,
+		PorterYamlPath:     inp.PorterYamlPath,
 		DeploymentTargetID: inp.DeploymentTargetID,
 	}
 

+ 29 - 201
api/server/handlers/porter_app/create_app.go

@@ -1,9 +1,7 @@
 package porter_app
 
 import (
-	"context"
 	"errors"
-	"fmt"
 	"net/http"
 
 	"connectrpc.com/connect"
@@ -16,10 +14,14 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
+// ErrMissingSourceType is returned when the source type is not specified
+var ErrMissingSourceType = errors.New("missing source type")
+
 // CreateAppHandler is the handler for the /apps/create endpoint
 type CreateAppHandler struct {
 	handlers.PorterHandlerReadWriter
@@ -36,38 +38,22 @@ func NewCreateAppHandler(
 	}
 }
 
-// ErrMissingSourceType is returned when the source type is not specified
-var ErrMissingSourceType = errors.New("missing source type")
-
-// SourceType is a string type specifying the source type of an app. This is specified in the incoming request
-type SourceType string
-
-const (
-	// SourceType_Github is the source kind for a github repo
-	SourceType_Github SourceType = "github"
-	// SourceType_DockerRegistry is the source kind for an app using an image from a docker registry
-	SourceType_DockerRegistry SourceType = "docker-registry"
-	// SourceType_Local is the source kind for an app being built locally
-	SourceType_Local SourceType = "other"
-)
-
-// Image is the image used by an app with a docker registry source
-type Image struct {
-	Repository string `json:"repository"`
-	Tag        string `json:"tag"`
+// GitSource is the github metadata for an app
+type GitSource struct {
+	GitBranch   string `json:"git_branch"`
+	GitRepoName string `json:"git_repo_name"`
+	GitRepoID   uint   `json:"git_repo_id"`
 }
 
 // CreateAppRequest is the request object for the /apps/create endpoint
 type CreateAppRequest struct {
-	Name                 string     `json:"name"`
-	SourceType           SourceType `json:"type"`
-	GitBranch            string     `json:"git_branch"`
-	GitRepoName          string     `json:"git_repo_name"`
-	GitRepoID            uint       `json:"git_repo_id"`
-	PorterYamlPath       string     `json:"porter_yaml_path"`
-	Image                *Image     `json:"image,omitempty"`
-	DeploymentTargetName string     `json:"deployment_target_name,omitempty"`
-	DeploymentTargetID   string     `json:"deployment_target_id,omitempty"`
+	GitSource            `json:",inline"`
+	SourceType           porter_app.SourceType `json:"type"`
+	PorterYamlPath       string                `json:"porter_yaml_path"`
+	Image                *porter_app.Image     `json:"image,omitempty"`
+	Name                 string                `json:"name"`
+	DeploymentTargetName string                `json:"deployment_target_name,omitempty"`
+	DeploymentTargetID   string                `json:"deployment_target_id,omitempty"`
 }
 
 // CreateGithubAppInput is the input for creating an app with a github source
@@ -127,123 +113,24 @@ func (c *CreateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: request.Name})
 
-	porterAppDBEntries, err := c.Repo().PorterApp().ReadPorterAppsByProjectIDAndName(project.ID, request.Name)
+	porterApp, err := porter_app.CreateOrGetAppRecord(ctx, porter_app.CreateOrGetAppRecordInput{
+		ClusterID:           cluster.ID,
+		ProjectID:           project.ID,
+		Name:                request.Name,
+		SourceType:          request.SourceType,
+		GitBranch:           request.GitBranch,
+		GitRepoName:         request.GitRepoName,
+		GitRepoID:           request.GitRepoID,
+		PorterYamlPath:      request.PorterYamlPath,
+		Image:               request.Image,
+		PorterAppRepository: c.Repo().PorterApp(),
+	})
 	if err != nil {
-		err := telemetry.Error(ctx, span, nil, "error reading porter apps by project id and name")
+		err := telemetry.Error(ctx, span, err, "error creating porter app")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	if len(porterAppDBEntries) > 1 {
-		err := telemetry.Error(ctx, span, nil, "multiple apps with same name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-	if len(porterAppDBEntries) == 1 {
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-app-id", Value: porterAppDBEntries[0].ID})
-		c.WriteResult(w, r, porterAppDBEntries[0].ToPorterAppType())
-		return
-	}
-
-	if request.SourceType == "" {
-		err := telemetry.Error(ctx, span, ErrMissingSourceType, "source type is required")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "source-type", Value: request.SourceType})
-
-	var porterApp *types.PorterApp
-	switch request.SourceType {
-	case SourceType_Github:
-		if request.GitRepoID == 0 {
-			err := telemetry.Error(ctx, span, nil, "git repo id is required")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		if request.GitBranch == "" {
-			err := telemetry.Error(ctx, span, nil, "git branch is required")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		if request.GitRepoName == "" {
-			err := telemetry.Error(ctx, span, nil, "git repo name is required")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "git-branch", Value: request.GitBranch},
-			telemetry.AttributeKV{Key: "git-repo-name", Value: request.GitRepoName},
-		)
-
-		input := CreateGithubAppInput{
-			ProjectID:           project.ID,
-			ClusterID:           cluster.ID,
-			Name:                request.Name,
-			GitRepoID:           request.GitRepoID,
-			GitBranch:           request.GitBranch,
-			GitRepoName:         request.GitRepoName,
-			PorterYamlPath:      request.PorterYamlPath,
-			PorterAppRepository: c.Repo().PorterApp(),
-		}
-
-		app, err := createGithubApp(ctx, input)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error creating github app")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
-		}
-		porterApp = app.ToPorterAppType()
-	case SourceType_DockerRegistry:
-		if request.Image == nil {
-			err := telemetry.Error(ctx, span, nil, "image is required")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "image-repo-uri", Value: fmt.Sprintf("%s:%s", request.Image.Repository, request.Image.Tag)},
-		)
-
-		input := CreateDockerRegistryAppInput{
-			ProjectID:           project.ID,
-			ClusterID:           cluster.ID,
-			Name:                request.Name,
-			Repository:          request.Image.Repository,
-			Tag:                 request.Image.Tag,
-			PorterAppRepository: c.Repo().PorterApp(),
-		}
-
-		app, err := createDockerRegistryApp(ctx, input)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error creating docker registry app")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
-		}
-		porterApp = app.ToPorterAppType()
-	case SourceType_Local:
-		input := CreateLocalAppInput{
-			ProjectID:           project.ID,
-			ClusterID:           cluster.ID,
-			Name:                request.Name,
-			PorterAppRepository: c.Repo().PorterApp(),
-		}
-
-		app, err := createLocalApp(ctx, input)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error creating other app")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
-		}
-		porterApp = app.ToPorterAppType()
-	default:
-		err := telemetry.Error(ctx, span, nil, "source type not supported")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-id", Value: porterApp.ID})
 
 	telemetry.WithAttributes(span,
@@ -278,62 +165,3 @@ func (c *CreateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	c.WriteResult(w, r, porterApp)
 }
-
-func createGithubApp(ctx context.Context, input CreateGithubAppInput) (*models.PorterApp, error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-github-app")
-	defer span.End()
-
-	porterApp := &models.PorterApp{
-		Name:           input.Name,
-		ProjectID:      input.ProjectID,
-		ClusterID:      input.ClusterID,
-		GitRepoID:      input.GitRepoID,
-		GitBranch:      input.GitBranch,
-		RepoName:       input.GitRepoName,
-		PorterYamlPath: input.PorterYamlPath,
-	}
-
-	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
-	if err != nil {
-		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
-	}
-
-	return porterApp, nil
-}
-
-func createDockerRegistryApp(ctx context.Context, input CreateDockerRegistryAppInput) (*models.PorterApp, error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-docker-registry-app")
-	defer span.End()
-
-	porterApp := &models.PorterApp{
-		Name:         input.Name,
-		ProjectID:    input.ProjectID,
-		ClusterID:    input.ClusterID,
-		ImageRepoURI: fmt.Sprintf("%s:%s", input.Repository, input.Tag),
-	}
-
-	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
-	if err != nil {
-		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
-	}
-
-	return porterApp, nil
-}
-
-func createLocalApp(ctx context.Context, input CreateLocalAppInput) (*models.PorterApp, error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-local-app")
-	defer span.End()
-
-	porterApp := &models.PorterApp{
-		Name:      input.Name,
-		ProjectID: input.ProjectID,
-		ClusterID: input.ClusterID,
-	}
-
-	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
-	if err != nil {
-		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
-	}
-
-	return porterApp, nil
-}

+ 381 - 0
api/server/handlers/porter_app/update_app.go

@@ -0,0 +1,381 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/deployment_target"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// UpdateAppHandler is the handler for the POST /apps/update endpoint
+type UpdateAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewUpdateAppHandler handles POST requests to the endpoint POST /apps/update
+func NewUpdateAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateAppHandler {
+	return &UpdateAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// CLIAction is an enum for actions the CLI should take after calling applyWithRevisionID
+type CLIAction int
+
+// Actions for the CLI to take after applying the porter yaml
+const (
+	// CLIAction_Missing is the zero value that indicates an action was not assigned. This should never be returned.
+	CLIAction_Missing CLIAction = iota
+	// CLIAction_NoAction indicates that no action should be taken by the CLI.
+	CLIAction_NoAction
+	// CLIAction_Build indicates that the CLI should build the app.
+	CLIAction_Build
+	// CLIAction_TrackPredeploy indicates that the CLI should track the predeploy job.
+	CLIAction_TrackPredeploy
+)
+
+// UpdateAppRequest is the request object for the POST /apps/update endpoint
+type UpdateAppRequest struct {
+	// Name is the name of the app to update. If not specified, the name will be inferred from the porter yaml
+	Name string `json:"name"`
+	// GitSource is the git source configuration for the app, if applicable
+	GitSource GitSource `json:"git_source,omitempty"`
+	// DeploymentTargetId is the ID of the deployment target to apply the update to
+	DeploymentTargetId string `json:"deployment_target_id"`
+	// Variables is a map of environment variable names to values
+	Variables map[string]string `json:"variables"`
+	// Secrets is a map of secret names to values
+	Secrets map[string]string `json:"secrets"`
+	// Deletions is the set of fields to delete before applying the update
+	Deletions Deletions `json:"deletions"`
+	// CommitSHA is the commit sha of the git commit that triggered this update, indicating a source change and triggering a build
+	CommitSHA string `json:"commit_sha"`
+	// PorterYAMLPath is the path to the porter yaml file in the git repo
+	PorterYAMLPath string `json:"porter_yaml_path"`
+	// AppRevisionID is the ID of the revision to perform follow up actions on after the initial apply
+	AppRevisionID string `json:"app_revision_id"`
+	// Only one of Base64AppProto or Base64PorterYAML should be specified
+	// Base64AppProto is a ful base64 encoded porter app contract to apply
+	Base64AppProto string `json:"b64_app_proto"`
+	// Base64PorterYAML is a base64 encoded porter yaml to apply representing a potentially partial porter app contract
+	Base64PorterYAML string `json:"b64_porter_yaml"`
+	// IsEnvOverride is used to remove any variables that are not specified in the request.  If false, the request will only update the variables specified in the request,
+	// and leave all other variables untouched.
+	IsEnvOverride bool `json:"is_env_override"`
+}
+
+// UpdateAppResponse is the response object for the POST /apps/update endpoint
+type UpdateAppResponse struct {
+	AppName       string    `json:"app_name"`
+	AppRevisionId string    `json:"app_revision_id"`
+	CLIAction     CLIAction `json:"cli_action"`
+}
+
+// ServeHTTP translates the request into an UpdateApp request, forwards to the cluster control plane, and returns the response
+func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-app")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &UpdateAppRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.Base64AppProto != "" && request.Base64PorterYAML != "" {
+		err := telemetry.Error(ctx, span, nil, "both b64 yaml and b64 porter yaml are specified")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.DeploymentTargetId == "" {
+		err := telemetry.Error(ctx, span, nil, "deployment target id is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	deploymentTargetID := request.DeploymentTargetId
+
+	var overrides *porterv1.PorterApp
+	appProto := &porterv1.PorterApp{}
+
+	envVariables := request.Variables
+
+	// get app definition from either base64 yaml or base64 porter app proto
+	if request.Base64AppProto != "" {
+		decoded, err := base64.StdEncoding.DecodeString(request.Base64AppProto)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error decoding base yaml")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		err = helpers.UnmarshalContractObject(decoded, appProto)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+	}
+
+	if request.Base64PorterYAML != "" {
+		decoded, err := base64.StdEncoding.DecodeString(request.Base64PorterYAML)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error decoding base yaml")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		appFromYaml, err := porter_app.ParseYAML(ctx, decoded, request.Name)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error parsing yaml")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+		appProto = appFromYaml.AppProto
+
+		// only public variables can be defined in porter.yaml
+		envVariables = mergeEnvVariables(request.Variables, appFromYaml.EnvVariables)
+
+		if appFromYaml.PreviewApp != nil {
+			overrides = appFromYaml.PreviewApp.AppProto
+			envVariables = mergeEnvVariables(envVariables, appFromYaml.PreviewApp.EnvVariables)
+		}
+	}
+
+	if appProto.Name == "" {
+		err := telemetry.Error(ctx, span, nil, "app name is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	sourceType, image := sourceFromAppAndGitSource(appProto, request.GitSource)
+
+	// create porter app if it doesn't exist for the given name
+	_, err := porter_app.CreateOrGetAppRecord(ctx, porter_app.CreateOrGetAppRecordInput{
+		ClusterID:           cluster.ID,
+		ProjectID:           project.ID,
+		Name:                appProto.Name,
+		SourceType:          sourceType,
+		GitBranch:           request.GitSource.GitBranch,
+		GitRepoName:         request.GitSource.GitRepoName,
+		GitRepoID:           request.GitSource.GitRepoID,
+		PorterYamlPath:      request.PorterYAMLPath,
+		Image:               image,
+		PorterAppRepository: c.Repo().PorterApp(),
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error creating or getting porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var appRevisionID string
+	if request.AppRevisionID != "" {
+		appRevisionID = request.AppRevisionID
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: request.AppRevisionID})
+	} else {
+		// set the internal porter domain if needed and this is the first update on a revision
+		app, err := v2.AppFromProto(appProto)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error converting app proto to app")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "app-name", Value: appProto.Name},
+			telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetId},
+		)
+
+		deploymentTargetDetails, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+			ProjectID:          int64(project.ID),
+			ClusterID:          int64(cluster.ID),
+			DeploymentTargetID: deploymentTargetID,
+			CCPClient:          c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		agent, err := c.GetAgent(r, cluster, "")
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting kubernetes agent")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		subdomainCreateInput := porter_app.CreatePorterSubdomainInput{
+			AppName:             app.Name,
+			RootDomain:          c.Config().ServerConf.AppRootDomain,
+			DNSClient:           c.Config().DNSClient,
+			DNSRecordRepository: c.Repo().DNSRecord(),
+			KubernetesAgent:     agent,
+		}
+
+		appWithDomains, err := addPorterSubdomainsIfNecessary(ctx, app, deploymentTargetDetails, subdomainCreateInput)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error adding porter subdomains")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		appProto, _, err = v2.ProtoFromApp(ctx, appWithDomains)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error converting app to proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+	}
+
+	var serviceDeletions map[string]*porterv1.ServiceDeletions
+	if request.Deletions.ServiceDeletions != nil {
+		serviceDeletions = make(map[string]*porterv1.ServiceDeletions)
+		for k, v := range request.Deletions.ServiceDeletions {
+			serviceDeletions[k] = &porterv1.ServiceDeletions{
+				DomainNames:        v.DomainNames,
+				IngressAnnotations: v.IngressAnnotationKeys,
+			}
+		}
+	}
+
+	updateReq := connect.NewRequest(&porterv1.UpdateAppRequest{
+		ProjectId: int64(project.ID),
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+			Id: deploymentTargetID,
+		},
+		App:           appProto,
+		AppRevisionId: appRevisionID,
+		AppEnv: &porterv1.EnvGroupVariables{
+			Normal: envVariables,
+			Secret: request.Secrets,
+		},
+		Deletions: &porterv1.Deletions{
+			ServiceNames:     request.Deletions.ServiceNames,
+			PredeployNames:   request.Deletions.Predeploy,
+			EnvVariableNames: request.Deletions.EnvVariableNames,
+			EnvGroupNames:    request.Deletions.EnvGroupNames,
+			ServiceDeletions: serviceDeletions,
+		},
+		AppOverrides:  overrides,
+		CommitSha:     request.CommitSHA,
+		IsEnvOverride: request.IsEnvOverride,
+	})
+
+	ccpResp, err := c.Config().ClusterControlPlaneClient.UpdateApp(ctx, updateReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp update app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp.Msg.AppRevisionId == "" {
+		err := telemetry.Error(ctx, span, err, "ccp resp app revision id is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "resp-app-revision-id", Value: ccpResp.Msg.AppRevisionId})
+
+	if ccpResp.Msg.CliAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_UNSPECIFIED {
+		err := telemetry.Error(ctx, span, err, "ccp resp cli action is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cli-action", Value: ccpResp.Msg.CliAction.String()})
+
+	var cliAction CLIAction
+	switch ccpResp.Msg.CliAction {
+	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_UNSPECIFIED:
+		cliAction = CLIAction_Missing
+	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE:
+		cliAction = CLIAction_NoAction
+	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD:
+		cliAction = CLIAction_Build
+	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_TRACK_PREDEPLOY:
+		cliAction = CLIAction_TrackPredeploy
+	default:
+		err := telemetry.Error(ctx, span, err, "ccp resp cli action is invalid")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := &UpdateAppResponse{
+		AppRevisionId: ccpResp.Msg.AppRevisionId,
+		CLIAction:     cliAction,
+		AppName:       appProto.Name,
+	}
+
+	c.WriteResult(w, r, response)
+}
+
+func sourceFromAppAndGitSource(appProto *porterv1.PorterApp, gitSource GitSource) (porter_app.SourceType, *porter_app.Image) {
+	var sourceType porter_app.SourceType
+	var image *porter_app.Image
+
+	if appProto.Build != nil {
+		if gitSource.GitRepoID == 0 {
+			return porter_app.SourceType_Local, nil
+		}
+
+		sourceType = porter_app.SourceType_Github
+	}
+
+	if appProto.Image != nil {
+		sourceType = porter_app.SourceType_DockerRegistry
+		image = &porter_app.Image{
+			Repository: appProto.Image.Repository,
+			Tag:        appProto.Image.Tag,
+		}
+	}
+
+	return sourceType, image
+}
+
+func mergeEnvVariables(currentEnv, previousEnv map[string]string) map[string]string {
+	env := make(map[string]string)
+
+	for k, v := range previousEnv {
+		env[k] = v
+	}
+	for k, v := range currentEnv {
+		env[k] = v
+	}
+
+	return env
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -862,6 +862,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/update -> porter_app.UpdateAppHandler
+	updateAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/update", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updateAppHandler := porter_app.NewUpdateAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateAppEndpoint,
+		Handler:  updateAppHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/revisions -> porter_app.NewCurrentAppRevisionHandler
 	latestAppRevisionsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 37 - 0
dashboard/.eslintrc.json

@@ -0,0 +1,37 @@
+{
+  "env": {
+    "browser": true,
+    "es2021": true
+  },
+  "root": true,
+  "extends": [
+    "standard-with-typescript",
+    "plugin:react/recommended",
+    "plugin:@typescript-eslint/recommended",
+    "prettier"
+  ],
+  "parser": "@typescript-eslint/parser",
+  "parserOptions": {
+    "ecmaVersion": "latest",
+    "sourceType": "module"
+  },
+  "plugins": ["react"],
+  "rules": {
+    "no-console": "error",
+    "@typescript-eslint/no-explicit-any": "error",
+    "@typescript-eslint/consistent-type-definitions": ["error", "type"],
+    "no-unused-expressions": "off",
+    "@typescript-eslint/no-unused-expressions": "error",
+    "no-unused-vars": "off",
+    "@typescript-eslint/no-unused-vars": [
+      "error",
+      {
+        "varsIgnorePattern": "^_",
+        "argsIgnorePattern": "^_",
+        "caughtErrorsIgnorePattern": "^_"
+      }
+    ],
+    "@typesecript-eslint/consistent-type-imports": "off",
+    "@typescript-eslint/strict-boolean-expressions": "off"
+  }
+}

+ 4 - 0
dashboard/.husky/pre-commit

@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+. "$(dirname -- "$0")/_/husky.sh"
+
+cd dashboard && npm run lint-staged

+ 27 - 1
dashboard/.prettierrc.json

@@ -1 +1,27 @@
-{}
+{
+  "endOfLine": "lf",
+  "semi": true,
+  "singleQuote": false,
+  "tabWidth": 2,
+  "trailingComma": "es5",
+  "importOrder": [
+    "^(react/(.*)$)|^(react$)",
+    "<THIRD_PARTY_MODULES>",
+    "",
+    "^components/(.*)$",
+    "^main/(.*)$",
+    "^lib/(.*)$",
+    "",
+    "^shared/(.*)$",
+    "^utils/(.*)$",
+    "^assets/(.*)$",
+    "^[./]"
+  ],
+  "importOrderSeparation": false,
+  "importOrderSortSpecifiers": true,
+  "importOrderBuiltinModulesToTop": true,
+  "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
+  "importOrderMergeDuplicateImports": true,
+  "importOrderCombineTypeAndValueImports": true,
+  "plugins": ["@ianvs/prettier-plugin-sort-imports"]
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 605 - 182
dashboard/package-lock.json


+ 26 - 3
dashboard/package.json

@@ -86,7 +86,9 @@
     "test": "echo \"Error: no test specified\" && exit 1",
     "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js",
     "build": "NODE_ENV=\"production\" webpack",
-    "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" ./node_modules/webpack/bin/webpack.js"
+    "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" ./node_modules/webpack/bin/webpack.js",
+    "prepare": "cd .. && husky install dashboard/.husky",
+    "lint-staged": "lint-staged"
   },
   "devDependencies": {
     "@babel/core": "^7.15.0",
@@ -94,6 +96,7 @@
     "@babel/preset-env": "^7.15.0",
     "@babel/preset-react": "^7.14.5",
     "@babel/preset-typescript": "^7.15.0",
+    "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
     "@porter-dev/api-contracts": "^0.2.28",
     "@testing-library/jest-dom": "^4.2.4",
@@ -128,14 +131,25 @@
     "@types/traverse": "^0.6.32",
     "@types/uuid": "^9.0.1",
     "@types/webpack-dev-server": "^3.11.5",
+    "@typescript-eslint/eslint-plugin": "^6.8.0",
+    "@typescript-eslint/parser": "^6.8.0",
     "babel-loader": "^8.2.2",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-styled-components": "^1.13.3",
     "cronstrue": "^2.28.0",
     "css-loader": "^5.2.6",
+    "eslint": "^8.51.0",
+    "eslint-config-prettier": "^9.0.0",
+    "eslint-config-standard-with-typescript": "^39.1.1",
+    "eslint-plugin-import": "^2.28.1",
+    "eslint-plugin-n": "^16.2.0",
+    "eslint-plugin-promise": "^6.1.1",
+    "eslint-plugin-react": "^7.33.2",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
-    "prettier": "2.2.1",
+    "husky": "^8.0.3",
+    "lint-staged": "^15.0.2",
+    "prettier": "^3.0.3",
     "qs": "^6.9.4",
     "react-beautiful-dnd": "^13.1.1",
     "react-collapse": "^5.1.1",
@@ -150,5 +164,14 @@
     "webpack-bundle-analyzer": "^4.4.2",
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"
+  },
+  "lint-staged": {
+    "**/*.{js,ts,jsx,tsx}": [
+      "eslint --fix",
+      "prettier --write"
+    ],
+    "**/*.(md|json)": [
+      "prettier --write"
+    ]
   }
-}
+}

+ 3 - 16
dashboard/src/components/ProvisionerFlow.tsx

@@ -33,13 +33,6 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
   const [useCloudFormationForm, setUseCloudFormationForm] = useState(true);
   const [selectedProvider, setSelectedProvider] = useState("");
 
-  const isUsageExceeded = useMemo(() => {
-    if (!hasBillingEnabled) {
-      return false;
-    }
-    return usage?.current.clusters >= usage?.limit.clusters;
-  }, [usage]);
-
   const markStepCostConsent = async (step: string, provider: string) => {
     try {
       await api.updateOnboardingStep("<token>", { step, provider }, { project_id: currentProject.id });
@@ -66,17 +59,11 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
                 <Block
                   key={i}
                   disabled={
-                    !currentProject?.multi_cluster && (isUsageExceeded ||
-                      (provider === "gcp" && !currentProject?.azure_enabled))
-
+                    !currentProject?.multi_cluster &&
+                    (provider === "gcp" && !currentProject?.azure_enabled)
                   }
                   onClick={() => {
-                    if (
-                      !(
-                        isUsageExceeded ||
-                        (provider === "gcp" && !currentProject?.azure_enabled)
-                      )
-                    ) {
+                    if ((provider != "gcp" || currentProject?.azure_enabled)) {
                       openCostConsentModal(provider);
                       // setSelectedProvider(provider);
                       // setCurrentStep("credentials");

+ 5 - 2
dashboard/src/main/home/database-dashboard/forms/AuroraPostgresForm.tsx

@@ -20,6 +20,7 @@ import Error from "components/porter/Error";
 import Fieldset from "components/porter/Fieldset";
 import Container from "components/porter/Container";
 import ClickToCopy from "components/porter/ClickToCopy";
+import { AuroraPostgresFormValues } from "./types";
 
 type Props = RouteComponentProps & {
   currentTemplate: any;
@@ -83,9 +84,11 @@ const AuroraPostgresForm: React.FC<Props> = ({
   const deploy = async () => {
     setButtonStatus("loading");
 
-    const values = {
+    const values: { config: AuroraPostgresFormValues } = {
       config: {
-        name,
+        name: name,
+        databaseName: dbName,
+        masterUsername: dbUsername,
         masterUserPassword: dbPassword,
         allocatedStorage: storage,
         instanceClass: tier,

+ 5 - 2
dashboard/src/main/home/database-dashboard/forms/RDSForm.tsx

@@ -20,6 +20,7 @@ import Error from "components/porter/Error";
 import Fieldset from "components/porter/Fieldset";
 import Container from "components/porter/Container";
 import ClickToCopy from "components/porter/ClickToCopy";
+import { RdsFormValues } from "./types";
 
 type Props = RouteComponentProps & {
   currentTemplate: any;
@@ -83,9 +84,11 @@ const RDSForm: React.FC<Props> = ({
   const deploy = async (wildcard?: any) => {
     setButtonStatus("loading");
 
-    let values = {
+    let values: { config: RdsFormValues } = {
       config: {
-        name,
+        name: name,
+        databaseName: dbName,
+        masterUsername: dbUsername,
         masterUserPassword: dbPassword,
         allocatedStorage: storage,
         instanceClass: tier,

+ 17 - 0
dashboard/src/main/home/database-dashboard/forms/types.ts

@@ -0,0 +1,17 @@
+export type RdsFormValues = {
+    name: string,
+    databaseName: string,
+    masterUsername: string,
+    masterUserPassword: string,
+    allocatedStorage: number,
+    instanceClass: string,
+};
+
+export type AuroraPostgresFormValues = {
+    name: string,
+    databaseName: string,
+    masterUsername: string,
+    masterUserPassword: string,
+    allocatedStorage: number,
+    instanceClass: string,
+};

+ 3 - 2
dashboard/src/main/home/sidebar/AddCluster/AWSCredentialList.tsx

@@ -56,11 +56,12 @@ const AWSCredentialsList: React.FunctionComponent<Props> = ({
         setHasError(true);
         setCurrentError(err.response?.data?.error);
         setIsLoading(false);
+
       });
   }, [currentProject]);
 
   if (hasError) {
-    return <Placeholder>Error</Placeholder>;
+    return <ProvisionerFlow />;
   }
 
   if (isLoading) {
@@ -85,7 +86,7 @@ const AWSCredentialsList: React.FunctionComponent<Props> = ({
           credential:
         </Description>
         <CredentialList
-          credentials={awsCredentials.map((cred) => {
+          credentials={awsCredentials?.map((cred) => {
             return {
               id: cred.id,
               display_name: cred.aws_arn,

+ 2 - 2
dashboard/src/main/home/sidebar/ClusterList.tsx

@@ -117,10 +117,10 @@ const ClusterList: React.FC<PropsType> = (props) => {
             <Img src={infra} />
             <ClusterName>{truncate(currentCluster.vanity_name ? currentCluster.vanity_name : currentCluster?.name)}</ClusterName>
 
-            {(clusters.length > 1 || user.isPorterUser) && <i className="material-icons">arrow_drop_down</i>}
+            <i className="material-icons">arrow_drop_down</i>
           </NavButton>
         </MainSelector>
-        {(clusters.length > 1) && renderDropdown()}
+        {renderDropdown()}
         {
           clusterModalVisible && <ProvisionClusterModal
             closeModal={() => setClusterModalVisible(false)} />

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.35
+	github.com/porter-dev/api-contracts v0.2.37
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 4
go.sum

@@ -1520,10 +1520,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.34 h1:UeQ+1c5NggYPUsof5Q1jy+l0hL5Z+/Q/PK532kAlF+w=
-github.com/porter-dev/api-contracts v0.2.34/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.35 h1:BDxOMKQrYvh/3qsSiUYWM+btBx4+oVjA2mH4+0C/sHY=
-github.com/porter-dev/api-contracts v0.2.35/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.37 h1:tt3gh6wGEpmeFUMCUwgN/SRfj6zeHNPYHnZI2I0e/Rw=
+github.com/porter-dev/api-contracts v0.2.37/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 2 - 2
go.work.sum

@@ -837,8 +837,8 @@ github.com/porter-dev/api-contracts v0.2.23 h1:AGyidwLoZedNB/iUIg/wgXXi/00BsMN4P
 github.com/porter-dev/api-contracts v0.2.23/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.27 h1:NZTWmbiqQF082Kl0vUtXev5gcI8lTY6bofjS4Sjwej4=
 github.com/porter-dev/api-contracts v0.2.27/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
-github.com/porter-dev/api-contracts v0.2.34 h1:UeQ+1c5NggYPUsof5Q1jy+l0hL5Z+/Q/PK532kAlF+w=
-github.com/porter-dev/api-contracts v0.2.34/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.35 h1:BDxOMKQrYvh/3qsSiUYWM+btBx4+oVjA2mH4+0C/sHY=
+github.com/porter-dev/api-contracts v0.2.35/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=

+ 255 - 0
internal/porter_app/create.go

@@ -0,0 +1,255 @@
+package porter_app
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ErrMissingSourceType is returned when the source type is not specified
+var ErrMissingSourceType = errors.New("missing source type")
+
+// SourceType is a string type specifying the source type of an app. This is specified in the incoming request
+type SourceType string
+
+const (
+	// SourceType_Github is the source kind for a github repo
+	SourceType_Github SourceType = "github"
+	// SourceType_DockerRegistry is the source kind for an app using an image from a docker registry
+	SourceType_DockerRegistry SourceType = "docker-registry"
+	// SourceType_Local is the source kind for an app being built locally
+	SourceType_Local SourceType = "other"
+)
+
+// Image is the image used by an app with a docker registry source
+type Image struct {
+	Repository string `json:"repository"`
+	Tag        string `json:"tag"`
+}
+
+// CreateGithubAppInput is the input for creating an app with a github source
+type CreateGithubAppInput struct {
+	ProjectID           uint
+	ClusterID           uint
+	Name                string
+	GitBranch           string
+	GitRepoName         string
+	PorterYamlPath      string
+	GitRepoID           uint
+	PorterAppRepository repository.PorterAppRepository
+}
+
+// CreateDockerRegistryAppInput is the input for creating an app with a docker registry source
+type CreateDockerRegistryAppInput struct {
+	ProjectID           uint
+	ClusterID           uint
+	Name                string
+	Repository          string
+	Tag                 string
+	PorterAppRepository repository.PorterAppRepository
+}
+
+// CreateLocalAppInput is the input for creating an app that is built locally via the cli
+type CreateLocalAppInput struct {
+	ProjectID           uint
+	ClusterID           uint
+	Name                string
+	PorterAppRepository repository.PorterAppRepository
+}
+
+// CreateOrGetAppRecordInput is the input to the CreateOrGetAppRecord function
+type CreateOrGetAppRecordInput struct {
+	ClusterID      uint
+	ProjectID      uint
+	Name           string
+	SourceType     SourceType
+	GitBranch      string
+	GitRepoName    string
+	GitRepoID      uint
+	PorterYamlPath string
+	Image          *Image
+
+	PorterAppRepository repository.PorterAppRepository
+}
+
+// CreateOrGetAppRecord creates an app based on the input or gets an existing app if one is found with the provided name
+func CreateOrGetAppRecord(ctx context.Context, input CreateOrGetAppRecordInput) (*types.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-create")
+	defer span.End()
+
+	var app *types.PorterApp
+
+	if input.ClusterID == 0 {
+		return app, telemetry.Error(ctx, span, nil, "cluster id is 0")
+	}
+	if input.ProjectID == 0 {
+		return app, telemetry.Error(ctx, span, nil, "project id is 0")
+	}
+	if input.Name == "" {
+		return app, telemetry.Error(ctx, span, nil, "name is empty")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: input.ProjectID},
+		telemetry.AttributeKV{Key: "name", Value: input.Name},
+	)
+
+	porterAppDBEntries, err := input.PorterAppRepository.ReadPorterAppsByProjectIDAndName(input.ProjectID, input.Name)
+	if err != nil {
+		return app, telemetry.Error(ctx, span, err, "error reading porter apps by project id and name")
+	}
+	if len(porterAppDBEntries) > 1 {
+		return app, telemetry.Error(ctx, span, nil, "multiple apps with same name")
+	}
+
+	// return existing app if one found with same name
+	if len(porterAppDBEntries) == 1 {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-app-id", Value: porterAppDBEntries[0].ID})
+		app = porterAppDBEntries[0].ToPorterAppType()
+		return app, nil
+	}
+
+	switch input.SourceType {
+	case SourceType_Github:
+		if input.GitRepoID == 0 {
+			return app, telemetry.Error(ctx, span, nil, "git repo id is 0")
+		}
+		if input.GitBranch == "" {
+			return app, telemetry.Error(ctx, span, nil, "git branch is empty")
+		}
+		if input.GitRepoName == "" {
+			return app, telemetry.Error(ctx, span, nil, "git repo name is empty")
+		}
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "git-repo-id", Value: input.GitRepoID},
+			telemetry.AttributeKV{Key: "git-branch", Value: input.GitBranch},
+			telemetry.AttributeKV{Key: "git-repo-name", Value: input.GitRepoName},
+		)
+
+		input := CreateGithubAppInput{
+			ProjectID:           input.ProjectID,
+			ClusterID:           input.ClusterID,
+			Name:                input.Name,
+			GitRepoID:           input.GitRepoID,
+			GitBranch:           input.GitBranch,
+			GitRepoName:         input.GitRepoName,
+			PorterYamlPath:      input.PorterYamlPath,
+			PorterAppRepository: input.PorterAppRepository,
+		}
+
+		githubApp, err := createGithubApp(ctx, input)
+		if err != nil {
+			return app, telemetry.Error(ctx, span, err, "error creating github app")
+		}
+		app = githubApp.ToPorterAppType()
+	case SourceType_DockerRegistry:
+		if input.Image == nil {
+			return app, telemetry.Error(ctx, span, nil, "image is required")
+		}
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "image-repo-uri", Value: fmt.Sprintf("%s:%s", input.Image.Repository, input.Image.Tag)},
+		)
+
+		input := CreateDockerRegistryAppInput{
+			ProjectID:           input.ProjectID,
+			ClusterID:           input.ClusterID,
+			Name:                input.Name,
+			Repository:          input.Image.Repository,
+			Tag:                 input.Image.Tag,
+			PorterAppRepository: input.PorterAppRepository,
+		}
+
+		dockerApp, err := createDockerRegistryApp(ctx, input)
+		if err != nil {
+			return app, telemetry.Error(ctx, span, err, "error creating docker registry app")
+		}
+		app = dockerApp.ToPorterAppType()
+	case SourceType_Local:
+		input := CreateLocalAppInput{
+			ProjectID:           input.ProjectID,
+			ClusterID:           input.ClusterID,
+			Name:                input.Name,
+			PorterAppRepository: input.PorterAppRepository,
+		}
+
+		localApp, err := createLocalApp(ctx, input)
+		if err != nil {
+			return app, telemetry.Error(ctx, span, err, "error creating local app")
+		}
+		app = localApp.ToPorterAppType()
+	default:
+		return app, telemetry.Error(ctx, span, ErrMissingSourceType, "missing source type")
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-id", Value: app.ID})
+
+	return app, nil
+}
+
+func createGithubApp(ctx context.Context, input CreateGithubAppInput) (*models.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-github-app")
+	defer span.End()
+
+	porterApp := &models.PorterApp{
+		Name:           input.Name,
+		ProjectID:      input.ProjectID,
+		ClusterID:      input.ClusterID,
+		GitRepoID:      input.GitRepoID,
+		GitBranch:      input.GitBranch,
+		RepoName:       input.GitRepoName,
+		PorterYamlPath: input.PorterYamlPath,
+	}
+
+	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
+	if err != nil {
+		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
+	}
+
+	return porterApp, nil
+}
+
+func createDockerRegistryApp(ctx context.Context, input CreateDockerRegistryAppInput) (*models.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-docker-registry-app")
+	defer span.End()
+
+	porterApp := &models.PorterApp{
+		Name:         input.Name,
+		ProjectID:    input.ProjectID,
+		ClusterID:    input.ClusterID,
+		ImageRepoURI: fmt.Sprintf("%s:%s", input.Repository, input.Tag),
+	}
+
+	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
+	if err != nil {
+		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
+	}
+
+	return porterApp, nil
+}
+
+// createLocalApp creates an app that is built locally via the cli, usually frmo a git repo
+// a local app will not have the same repo metadata as a github app
+func createLocalApp(ctx context.Context, input CreateLocalAppInput) (*models.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-local-app")
+	defer span.End()
+
+	porterApp := &models.PorterApp{
+		Name:      input.Name,
+		ProjectID: input.ProjectID,
+		ClusterID: input.ClusterID,
+	}
+
+	porterApp, err := input.PorterAppRepository.CreatePorterApp(porterApp)
+	if err != nil {
+		return porterApp, telemetry.Error(ctx, span, err, "error creating porter app")
+	}
+
+	return porterApp, nil
+}

+ 20 - 11
internal/registry/registry.go

@@ -503,19 +503,28 @@ func (r *Registry) listECRRepositories(aws *ints.AWSIntegration) ([]*ptypes.Regi
 
 	svc := ecr.New(sess)
 
-	resp, err := svc.DescribeRepositories(&ecr.DescribeRepositoriesInput{})
-	if err != nil {
-		return nil, err
-	}
-
 	res := make([]*ptypes.RegistryRepository, 0)
+	input := &ecr.DescribeRepositoriesInput{}
 
-	for _, repo := range resp.Repositories {
-		res = append(res, &ptypes.RegistryRepository{
-			Name:      *repo.RepositoryName,
-			CreatedAt: *repo.CreatedAt,
-			URI:       *repo.RepositoryUri,
-		})
+	for {
+		resp, err := svc.DescribeRepositories(input)
+		if err != nil {
+			return nil, err
+		}
+
+		for _, repo := range resp.Repositories {
+			res = append(res, &ptypes.RegistryRepository{
+				Name:      *repo.RepositoryName,
+				CreatedAt: *repo.CreatedAt,
+				URI:       *repo.RepositoryUri,
+			})
+		}
+
+		if resp.NextToken == nil {
+			break
+		}
+
+		input.NextToken = resp.NextToken
 	}
 
 	return res, nil

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác