Browse Source

move away from gitinstallation endpoints

Mohammed Nafees 3 năm trước cách đây
mục cha
commit
fa413341fd

+ 16 - 36
api/client/environment.go

@@ -24,17 +24,13 @@ func (c *Client) ListEnvironments(
 
 func (c *Client) CreateDeployment(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
+	projID, clusterID uint,
 	req *types.CreateDeploymentRequest,
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 
 	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
+		fmt.Sprintf("/projects/%d/clusters/%d/deployments", projID, clusterID),
 		req,
 		resp,
 	)
@@ -60,17 +56,13 @@ func (c *Client) GetDeployment(
 
 func (c *Client) UpdateDeployment(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.UpdateDeploymentRequest,
+	projID, clusterID uint,
+	req *types.UpdateDeploymentByClusterRequest,
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/update",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
+	err := c.patchRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/deployments", projID, clusterID),
 		req,
 		resp,
 	)
@@ -80,17 +72,13 @@ func (c *Client) UpdateDeployment(
 
 func (c *Client) UpdateDeploymentStatus(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.UpdateDeploymentStatusRequest,
+	projID, clusterID uint,
+	req *types.UpdateDeploymentStatusByClusterRequest,
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/update/status",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
+	err := c.patchRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/deployments/status", projID, clusterID),
 		req,
 		resp,
 	)
@@ -100,17 +88,13 @@ func (c *Client) UpdateDeploymentStatus(
 
 func (c *Client) FinalizeDeployment(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.FinalizeDeploymentRequest,
+	projID, clusterID uint,
+	req *types.FinalizeDeploymentByClusterRequest,
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 
 	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/finalize",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
+		fmt.Sprintf("/projects/%d/clusters/%d/deployments/finalize", projID, clusterID),
 		req,
 		resp,
 	)
@@ -120,17 +104,13 @@ func (c *Client) FinalizeDeployment(
 
 func (c *Client) FinalizeDeploymentWithErrors(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.FinalizeDeploymentWithErrorsRequest,
+	projID, clusterID uint,
+	req *types.FinalizeDeploymentWithErrorsByClusterRequest,
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 
 	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/finalize_errors",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
+		fmt.Sprintf("/projects/%d/clusters/%d/deployments/finalize_errors", projID, clusterID),
 		req,
 		resp,
 	)

+ 127 - 0
api/server/handlers/environment/create_deployment_by_cluster.go

@@ -0,0 +1,127 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type CreateDeploymentByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateDeploymentByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateDeploymentByClusterHandler {
+	return &CreateDeploymentByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreateDeploymentRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
+		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
+	)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(
+				fmt.Errorf("error creating deployment: %w", errEnvironmentNotFound)),
+			)
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// add a check for Github PR status
+	prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(request.PullRequestID))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+		return
+	}
+
+	if prClosed {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("attempting to create deployment for a closed github PR"), http.StatusConflict,
+		))
+		return
+	}
+
+	ghDeployment, err := createGithubDeployment(client, env, request.PRBranchFrom, request.ActionID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+		return
+	}
+
+	// create the deployment
+	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
+		EnvironmentID:  env.ID,
+		Namespace:      request.Namespace,
+		Status:         types.DeploymentStatusCreating,
+		PullRequestID:  request.PullRequestID,
+		GHDeploymentID: ghDeployment.GetID(),
+		RepoOwner:      request.GitHubMetadata.RepoOwner,
+		RepoName:       request.GitHubMetadata.RepoName,
+		PRName:         request.GitHubMetadata.PRName,
+		CommitSHA:      request.GitHubMetadata.CommitSHA,
+		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
+		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
+	})
+
+	if err != nil {
+		// try to delete the GitHub deployment
+		_, err = client.Repositories.DeleteDeployment(
+			context.Background(),
+			env.GitRepoOwner,
+			env.GitRepoName,
+			ghDeployment.GetID(),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
+				http.StatusConflict))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating deployment: %w", err)))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 186 - 0
api/server/handlers/environment/finalize_deployment_by_cluster.go

@@ -0,0 +1,186 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+	"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/models"
+	"gorm.io/gorm"
+)
+
+type FinalizeDeploymentByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewFinalizeDeploymentByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *FinalizeDeploymentByClusterHandler {
+	return &FinalizeDeploymentByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *FinalizeDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.FinalizeDeploymentByClusterRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
+		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
+	)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var depl *models.Deployment
+
+	// read the deployment
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(
+			env.ID, request.RepoOwner, request.RepoName, request.PRNumber,
+		)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+		return
+	}
+
+	depl.Subdomain = request.Subdomain
+	depl.Status = types.DeploymentStatusCreated
+	depl.LastErrors = ""
+
+	// update the deployment
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// Create new deployment status to indicate deployment is ready
+
+	state := "success"
+	env_url := depl.Subdomain
+
+	deploymentStatusRequest := github.DeploymentStatusRequest{
+		State:          &state,
+		EnvironmentURL: &env_url,
+	}
+
+	_, _, err = client.Repositories.CreateDeploymentStatus(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		depl.GHDeploymentID,
+		&deploymentStatusRequest,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if !depl.IsBranchDeploy() {
+		// add a check for the PR to be open before creating a comment
+		prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(depl.PullRequestID))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+					depl.ID, err), http.StatusConflict,
+			))
+			return
+		}
+
+		if prClosed {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+				http.StatusConflict))
+			return
+		}
+
+		commentBody := "## Porter Preview Environments\n"
+
+		if depl.Subdomain == "" {
+			commentBody += fmt.Sprintf(
+				"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed.",
+				depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA,
+			)
+		} else {
+			commentBody += fmt.Sprintf(
+				"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed to %s",
+				depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain,
+			)
+		}
+
+		err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 205 - 0
api/server/handlers/environment/finalize_deployment_with_errors_by_cluster.go

@@ -0,0 +1,205 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/google/go-github/v41/github"
+	"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/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type FinalizeDeploymentWithErrorsByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewFinalizeDeploymentWithErrorsByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *FinalizeDeploymentWithErrorsByClusterHandler {
+	return &FinalizeDeploymentWithErrorsByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *FinalizeDeploymentWithErrorsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.FinalizeDeploymentWithErrorsByClusterRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	if len(request.Errors) == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("at least one error is required to report"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
+	var err error
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
+		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
+	)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var depl *models.Deployment
+
+	// read the deployment
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(
+			env.ID, request.RepoOwner, request.RepoName, request.PRNumber,
+		)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Status = types.DeploymentStatusFailed
+
+	var lastErrors []string
+
+	for resName, errString := range request.Errors {
+		lastErrors = append(lastErrors, fmt.Sprintf("%s: %s", resName, errString))
+	}
+
+	depl.LastErrors = strings.Join(lastErrors, ",")
+
+	// we do not care of the error in this case because the list deployments endpoint
+	// talks to the github API to fetch the deployment status correctly
+	c.Repo().Environment().UpdateDeployment(depl)
+
+	// FIXME: ignore the status of this API call for now
+	client.Repositories.CreateDeploymentStatus(
+		context.Background(), request.RepoOwner, request.RepoName, depl.GHDeploymentID, &github.DeploymentStatusRequest{
+			State:       github.String("failure"),
+			Description: github.String("one or more resources failed to build"),
+		},
+	)
+
+	if !depl.IsBranchDeploy() {
+		// add a check for the PR to be open before creating a comment
+		prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(depl.PullRequestID))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
+			return
+		}
+
+		if prClosed {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("github PR has been closed"),
+				http.StatusConflict))
+			return
+		}
+
+		workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
+			fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		commentBody := fmt.Sprintf(
+			"## Porter Preview Environments\n"+
+				"❌ Errors encountered while deploying the changes\n"+
+				"||Deployment Information|\n"+
+				"|-|-|\n"+
+				"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
+				"| Build Logs | %s |\n",
+			depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, workflowRun.GetHTMLURL(),
+		)
+
+		if len(request.SuccessfulResources) > 0 {
+			commentBody += "#### Successfully deployed resources\n"
+
+			for _, res := range request.SuccessfulResources {
+				if res.ReleaseType == "job" {
+					commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+						res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+						res.ReleaseName, project.ID)
+				} else {
+					commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+						res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+						res.ReleaseName, project.ID)
+				}
+			}
+		}
+
+		commentBody += "#### Failed resources\n"
+
+		for res, err := range request.Errors {
+			commentBody += fmt.Sprintf("<details>\n  <summary><code>%s</code></summary>\n\n  **Error:** %s\n</details>\n", res, err)
+		}
+
+		err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 154 - 0
api/server/handlers/environment/update_deployment_by_cluster.go

@@ -0,0 +1,154 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateDeploymentByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateDeploymentByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateDeploymentByClusterHandler {
+	return &UpdateDeploymentByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateDeploymentByClusterRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Namespace == "" && request.PRNumber == 0 && request.PRBranchFrom == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace, pr_number or pr_branch_from must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
+		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentForBranch(
+		env.ID, request.RepoOwner, request.RepoName, request.PRBranchFrom,
+	)
+
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl == nil {
+		if request.PRNumber != 0 {
+			depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(
+				env.ID, request.RepoOwner, request.RepoName, request.PRNumber,
+			)
+
+			if err != nil {
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+					return
+				}
+
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		} else if request.Namespace != "" {
+			depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+			if err != nil {
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+					return
+				}
+
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+		return
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if !depl.IsBranchDeploy() {
+		// add a check for the PR to be open before creating a comment
+		prClosed, err := isGithubPRClosed(client, request.RepoOwner, request.RepoName, int(depl.PullRequestID))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+					depl.ID, err), http.StatusConflict,
+			))
+			return
+		}
+
+		if prClosed {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+				http.StatusConflict))
+			return
+		}
+	}
+
+	ghDeployment, err := createGithubDeployment(client, env, request.PRBranchFrom, request.ActionID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Namespace = request.Namespace
+	depl.GHDeploymentID = ghDeployment.GetID()
+	depl.CommitSHA = request.CommitSHA
+
+	// update the deployment
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 157 - 0
api/server/handlers/environment/update_deployment_status_by_cluster.go

@@ -0,0 +1,157 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateDeploymentStatusByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateDeploymentStatusByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateDeploymentStatusByClusterHandler {
+	return &UpdateDeploymentStatusByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateDeploymentStatusByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.UpdateDeploymentStatusByClusterRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(
+		project.ID, cluster.ID, request.RepoOwner, request.RepoName,
+	)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentForBranch(
+		env.ID, request.RepoOwner, request.RepoName, request.PRBranchFrom,
+	)
+
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl == nil {
+		if request.PRNumber != 0 {
+			depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(
+				env.ID, request.RepoOwner, request.RepoName, request.PRNumber,
+			)
+
+			if err != nil {
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+					return
+				}
+
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		} else if request.Namespace != "" {
+			depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+			if err != nil {
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+					return
+				}
+
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("unable to get github client: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	if !depl.IsBranchDeploy() {
+		// add a check for the PR to be open before creating a comment
+		prClosed, err := isGithubPRClosed(client, depl.RepoOwner, depl.RepoName, int(depl.PullRequestID))
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
+					depl.ID, err), http.StatusConflict,
+			))
+			return
+		}
+
+		if prClosed {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+				http.StatusConflict))
+			return
+		}
+	}
+
+	if depl.Status == types.DeploymentStatusInactive && request.Status != string(types.DeploymentStatusCreating) {
+		// a deployment from "inactive" state can only transition to "creating"
+		c.WriteResult(w, r, depl.ToDeploymentType())
+		return
+	}
+
+	depl.Status = types.DeploymentStatus(request.Status)
+
+	// create the deployment
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 152 - 0
api/server/router/cluster.go

@@ -408,6 +408,36 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewCreateDeploymentByClusterHandler
+		createDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+					types.PreviewEnvironmentScope,
+				},
+			},
+		)
+
+		createDeploymentHandler := environment.NewCreateDeploymentByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: createDeploymentEndpoint,
+			Handler:  createDeploymentHandler,
+			Router:   r,
+		})
+
 		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
 		listDeploymentsEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
@@ -438,6 +468,66 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewUpdateDeploymentByClusterHandler
+		updateDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+					types.PreviewEnvironmentScope,
+				},
+			},
+		)
+
+		updateDeploymentHandler := environment.NewUpdateDeploymentByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: updateDeploymentEndpoint,
+			Handler:  updateDeploymentHandler,
+			Router:   r,
+		})
+
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/deployments/status -> environment.NewUpdateDeploymentStatusByClusterHandler
+		updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/status",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+					types.PreviewEnvironmentScope,
+				},
+			},
+		)
+
+		updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: updateDeploymentStatusEndpoint,
+			Handler:  updateDeploymentStatusHandler,
+			Router:   r,
+		})
+
 		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
 		getDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
@@ -468,6 +558,68 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/finalize ->
+		// environment.NewFinalizeDeploymentByClusterHandler
+		finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/finalize",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+					types.PreviewEnvironmentScope,
+				},
+			},
+		)
+
+		finalizeDeploymentHandler := environment.NewFinalizeDeploymentByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: finalizeDeploymentEndpoint,
+			Handler:  finalizeDeploymentHandler,
+			Router:   r,
+		})
+
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/finalize_errors ->
+		// environment.NewFinalizeDeploymentWithErrorsHandler
+		finalizeDeploymentWithErrorsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "deployments/finalize_errors",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+					types.PreviewEnvironmentScope,
+				},
+			},
+		)
+
+		finalizeDeploymentWithErrorsHandler := environment.NewFinalizeDeploymentWithErrorsByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: finalizeDeploymentWithErrorsEndpoint,
+			Handler:  finalizeDeploymentWithErrorsHandler,
+			Router:   r,
+		})
+
 		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id}/reenable -> environment.NewReenableDeploymentHandler
 		reenableDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{

+ 44 - 0
api/types/environment.go

@@ -90,6 +90,16 @@ type FinalizeDeploymentRequest struct {
 	Namespace           string                          `json:"namespace"`
 }
 
+type FinalizeDeploymentByClusterRequest struct {
+	RepoOwner string `json:"gh_repo_owner" form:"required"`
+	RepoName  string `json:"gh_repo_name" form:"required"`
+
+	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
+	Subdomain           string                          `json:"subdomain"`
+	PRNumber            uint                            `json:"pr_number"`
+	Namespace           string                          `json:"namespace"`
+}
+
 type FinalizeDeploymentWithErrorsRequest struct {
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Errors              map[string]string               `json:"errors" form:"required"`
@@ -97,6 +107,16 @@ type FinalizeDeploymentWithErrorsRequest struct {
 	Namespace           string                          `json:"namespace"`
 }
 
+type FinalizeDeploymentWithErrorsByClusterRequest struct {
+	RepoOwner string `json:"gh_repo_owner" form:"required"`
+	RepoName  string `json:"gh_repo_name" form:"required"`
+
+	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
+	Errors              map[string]string               `json:"errors" form:"required"`
+	PRNumber            uint                            `json:"pr_number"`
+	Namespace           string                          `json:"namespace"`
+}
+
 type UpdateDeploymentRequest struct {
 	*CreateGHDeploymentRequest
 
@@ -106,6 +126,18 @@ type UpdateDeploymentRequest struct {
 	Namespace    string `json:"namespace"`
 }
 
+type UpdateDeploymentByClusterRequest struct {
+	*CreateGHDeploymentRequest
+
+	RepoOwner string `json:"gh_repo_owner" form:"required"`
+	RepoName  string `json:"gh_repo_name" form:"required"`
+
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	CommitSHA    string `json:"commit_sha" form:"required"`
+	PRNumber     uint   `json:"pr_number"`
+	Namespace    string `json:"namespace"`
+}
+
 type ListDeploymentRequest struct {
 	EnvironmentID uint `schema:"environment_id"`
 }
@@ -119,6 +151,18 @@ type UpdateDeploymentStatusRequest struct {
 	Namespace    string `json:"namespace"`
 }
 
+type UpdateDeploymentStatusByClusterRequest struct {
+	*CreateGHDeploymentRequest
+
+	RepoOwner string `json:"gh_repo_owner" form:"required"`
+	RepoName  string `json:"gh_repo_name" form:"required"`
+
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
+	PRNumber     uint   `json:"pr_number"`
+	Namespace    string `json:"namespace"`
+}
+
 type DeleteDeploymentRequest struct {
 	Namespace string `json:"namespace" form:"required"`
 }

+ 18 - 28
cli/cmd/apply.go

@@ -909,11 +909,12 @@ func (t *DeploymentHook) PreApply() error {
 
 		_, err = t.client.CreateDeployment(
 			context.Background(),
-			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName, createReq,
+			t.projectID, t.clusterID, createReq,
 		)
 	} else if err == nil {
-		updateReq := &types.UpdateDeploymentRequest{
+		updateReq := &types.UpdateDeploymentByClusterRequest{
+			RepoOwner: t.repoOwner,
+			RepoName:  t.repoName,
 			Namespace: t.namespace,
 			PRNumber:  t.prID,
 			CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
@@ -927,11 +928,7 @@ func (t *DeploymentHook) PreApply() error {
 			updateReq.PRNumber = 0
 		}
 
-		_, err = t.client.UpdateDeployment(
-			context.Background(),
-			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName, updateReq,
-		)
+		_, err = t.client.UpdateDeployment(context.Background(), t.projectID, t.clusterID, updateReq)
 	}
 
 	return err
@@ -1012,7 +1009,9 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 		}
 	}
 
-	req := &types.FinalizeDeploymentRequest{
+	req := &types.FinalizeDeploymentByClusterRequest{
+		RepoOwner: t.repoOwner,
+		RepoName:  t.repoName,
 		Subdomain: strings.Join(subdomains, ", "),
 	}
 
@@ -1035,11 +1034,7 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 	}
 
 	// finalize the deployment
-	_, err := t.client.FinalizeDeployment(
-		context.Background(),
-		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName, req,
-	)
+	_, err := t.client.FinalizeDeployment(context.Background(), t.projectID, t.clusterID, req)
 
 	return err
 }
@@ -1067,7 +1062,9 @@ func (t *DeploymentHook) OnError(error) {
 
 	// if the deployment exists, throw an error for that deployment
 	if deplErr == nil {
-		req := &types.UpdateDeploymentStatusRequest{
+		req := &types.UpdateDeploymentStatusByClusterRequest{
+			RepoOwner: t.repoOwner,
+			RepoName:  t.repoName,
 			CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				ActionID: t.actionID,
 			},
@@ -1082,11 +1079,7 @@ func (t *DeploymentHook) OnError(error) {
 		}
 
 		// FIXME: try to use the error with a custom logger
-		t.client.UpdateDeploymentStatus(
-			context.Background(),
-			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName, req,
-		)
+		t.client.UpdateDeploymentStatus(context.Background(), t.projectID, t.clusterID, req)
 	}
 }
 
@@ -1113,8 +1106,10 @@ func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
 
 	// if the deployment exists, throw an error for that deployment
 	if deplErr == nil {
-		req := &types.FinalizeDeploymentWithErrorsRequest{
-			Errors: make(map[string]string),
+		req := &types.FinalizeDeploymentWithErrorsByClusterRequest{
+			RepoOwner: t.repoOwner,
+			RepoName:  t.repoName,
+			Errors:    make(map[string]string),
 		}
 
 		if t.isBranchDeploy() {
@@ -1137,12 +1132,7 @@ func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
 		}
 
 		// FIXME: handle the error
-		t.client.FinalizeDeploymentWithErrors(
-			context.Background(),
-			t.projectID, t.gitInstallationID, t.clusterID,
-			t.repoOwner, t.repoName,
-			req,
-		)
+		t.client.FinalizeDeploymentWithErrors(context.Background(), t.projectID, t.clusterID, req)
 	}
 }