Skip to content

Commit

Permalink
POR-1852 comment deploy details with URL on first preview deploy (#3759)
Browse files Browse the repository at this point in the history
  • Loading branch information
ianedwards authored Oct 9, 2023
1 parent c3a214a commit 96d5360
Show file tree
Hide file tree
Showing 10 changed files with 438 additions and 20 deletions.
34 changes: 34 additions & 0 deletions api/client/porter_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,40 @@ func (c *Client) GetBuildEnv(
return resp, err
}

// ReportRevisionStatusInput is the input struct to ReportRevisionStatus
type ReportRevisionStatusInput struct {
ProjectID uint
ClusterID uint
AppName string
AppRevisionID string
PRNumber int
CommitSHA string
}

// ReportRevisionStatus reports the status of an app revision to external services
func (c *Client) ReportRevisionStatus(
ctx context.Context,
inp ReportRevisionStatusInput,
) (*porter_app.ReportRevisionStatusResponse, error) {
resp := &porter_app.ReportRevisionStatusResponse{}

req := &porter_app.ReportRevisionStatusRequest{
PRNumber: inp.PRNumber,
CommitSHA: inp.CommitSHA,
}

err := c.postRequest(
fmt.Sprintf(
"/projects/%d/clusters/%d/apps/%s/revisions/%s/status",
inp.ProjectID, inp.ClusterID, inp.AppName, inp.AppRevisionID,
),
req,
resp,
)

return resp, err
}

// CreateOrUpdateAppEnvironment updates the app environment group and creates it if it doesn't exist
func (c *Client) CreateOrUpdateAppEnvironment(
ctx context.Context,
Expand Down
2 changes: 1 addition & 1 deletion api/server/handlers/porter_app/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp

if !webConfig.GetPrivate() && len(webConfig.Domains) == 0 {
if deploymentTarget.Namespace != DeploymentTargetSelector_Default {
createSubdomainInput.AppName = fmt.Sprintf("%s-%s", createSubdomainInput.AppName, deploymentTarget.Namespace)
createSubdomainInput.AppName = fmt.Sprintf("%s-%s", createSubdomainInput.AppName, deploymentTarget.ID[:6])
}

subdomain, err := porter_app.CreatePorterSubdomain(ctx, createSubdomainInput)
Expand Down
261 changes: 261 additions & 0 deletions api/server/handlers/porter_app/report_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package porter_app

import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"

"github.com/google/go-github/v39/github"
"github.com/google/uuid"
"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/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/deployment_target"
"github.com/porter-dev/porter/internal/models"
"github.com/porter-dev/porter/internal/porter_app"
"github.com/porter-dev/porter/internal/telemetry"
"k8s.io/utils/pointer"
)

// ReportRevisionStatusHandler is the handler for the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
type ReportRevisionStatusHandler struct {
handlers.PorterHandlerReadWriter
}

// NewReportRevisionStatusHandler handles POST requests to the endpoint /apps/{porter_app_name}/revisions/{app_revision_id}/status
func NewReportRevisionStatusHandler(
config *config.Config,
decoderValidator shared.RequestDecoderValidator,
writer shared.ResultWriter,
) *ReportRevisionStatusHandler {
return &ReportRevisionStatusHandler{
PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
}
}

// ReportRevisionStatusRequest is the request object for the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
type ReportRevisionStatusRequest struct {
PRNumber int `json:"pr_number"`
CommitSHA string `json:"commit_sha"`
}

// ReportRevisionStatusResponse is the response object for the /apps/{porter_app_name}/revisions/{app_revision_id}/status endpoint
type ReportRevisionStatusResponse struct{}

// ServeHTTP reports the status of a revision to Github and other integrations, depending on the status and the deployment target
func (c *ReportRevisionStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "serve-report-revision-status")
defer span.End()

project, _ := ctx.Value(types.ProjectScope).(*models.Project)
cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)

if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
return
}

appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
if reqErr != nil {
err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-name", Value: appName})

revisionID, reqErr := requestutils.GetURLParamString(r, types.URLParamAppRevisionID)
if reqErr != nil {
err := telemetry.Error(ctx, span, nil, "error parsing app revision id")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: revisionID})

appRevisionUuid, err := uuid.Parse(revisionID)
if err != nil {
err := telemetry.Error(ctx, span, err, "error parsing app revision id")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}
if appRevisionUuid == uuid.Nil {
err := telemetry.Error(ctx, span, nil, "app revision id is nil")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionUuid.String()})

porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
if err != nil {
err := telemetry.Error(ctx, span, err, "error reading porter app by name")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}
if porterApp.ID == 0 {
err := telemetry.Error(ctx, span, nil, "porter app not found")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
return
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApp.ID})

request := &ReportRevisionStatusRequest{}
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
}

revision, err := porter_app.GetAppRevision(ctx, porter_app.GetAppRevisionInput{
AppRevisionID: appRevisionUuid,
ProjectID: project.ID,
CCPClient: c.Config().ClusterControlPlaneClient,
})
if err != nil {
err := telemetry.Error(ctx, span, err, "error getting app revision")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
ProjectID: int64(project.ID),
ClusterID: int64(cluster.ID),
DeploymentTargetID: revision.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
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID})

resp := &ReportRevisionStatusResponse{}

if !deploymentTarget.Preview || request.PRNumber == 0 || revision.RevisionNumber > 1 {
c.WriteResult(w, r, resp)
return
}

err = writePRComment(ctx, writePRCommentInput{
revision: revision,
porterApp: porterApp,
prNumber: request.PRNumber,
commitSha: request.CommitSHA,
githubAppSecret: c.Config().ServerConf.GithubAppSecret,
githubAppID: c.Config().ServerConf.GithubAppID,
})
if err != nil {
err := telemetry.Error(ctx, span, err, "error writing pr comment")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

c.WriteResult(w, r, resp)
}

type writePRCommentInput struct {
revision porter_app.Revision
porterApp *models.PorterApp
prNumber int
commitSha string
serverURL string

githubAppSecret []byte
githubAppID string
}

func writePRComment(ctx context.Context, inp writePRCommentInput) error {
ctx, span := telemetry.NewSpan(ctx, "write-pr-comment")
defer span.End()

if inp.porterApp == nil {
return telemetry.Error(ctx, span, nil, "porter app is nil")
}
if inp.prNumber == 0 {
return telemetry.Error(ctx, span, nil, "pr number is empty")
}
if inp.commitSha == "" {
return telemetry.Error(ctx, span, nil, "commit sha is empty")
}
if inp.githubAppSecret == nil {
return telemetry.Error(ctx, span, nil, "github app secret is empty")
}
if inp.githubAppID == "" {
return telemetry.Error(ctx, span, nil, "github app id is empty")
}

client, err := porter_app.GetGithubClientByRepoID(ctx, inp.porterApp.GitRepoID, inp.githubAppSecret, inp.githubAppID)
if err != nil {
return telemetry.Error(ctx, span, err, "error getting github client")
}

repoDetails := strings.Split(inp.porterApp.RepoName, "/")
if len(repoDetails) != 2 {
return telemetry.Error(ctx, span, nil, "repo name is not in the format <org>/<repo>")
}

telemetry.WithAttributes(span,
telemetry.AttributeKV{Key: "repo-owner", Value: repoDetails[0]},
telemetry.AttributeKV{Key: "repo-name", Value: repoDetails[1]},
telemetry.AttributeKV{Key: "pr-number", Value: inp.prNumber},
telemetry.AttributeKV{Key: "commit-sha", Value: inp.commitSha},
)

decoded, err := base64.StdEncoding.DecodeString(inp.revision.B64AppProto)
if err != nil {
return telemetry.Error(ctx, span, err, "error decoding base proto")
}

appProto := &porterv1.PorterApp{}
err = helpers.UnmarshalContractObject(decoded, appProto)
if err != nil {
return telemetry.Error(ctx, span, err, "error unmarshalling app proto")
}

body := "## Porter Preview Environments\n"
porterURL := fmt.Sprintf("%s/preview-environments/apps/%s?target=%s", inp.serverURL, inp.porterApp.Name, inp.revision.DeploymentTargetID)

switch inp.revision.Status {
case models.AppRevisionStatus_BuildFailed:
body = fmt.Sprintf("%s❌ The latest deploy failed to build. Check the [Porter Dashboard](%s) or [action logs](https://github.com/%s/actions/runs/) for more information.", body, porterURL, inp.porterApp.RepoName)
case models.AppRevisionStatus_DeployFailed:
body = fmt.Sprintf("%s❌ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) failed to deploy.\nCheck the [Porter Dashboard](%s) or [action logs](https://github.com/%s/actions/runs/) for more information.\nContact Porter Support if the errors persists", body, inp.commitSha, repoDetails[0], repoDetails[1], inp.commitSha, porterURL, inp.porterApp.RepoName)
case models.AppRevisionStatus_Deployed:
body = fmt.Sprintf("%s✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed.\nApp details available in the [Porter Dashboard](%s)", body, inp.commitSha, repoDetails[0], repoDetails[1], inp.commitSha, porterURL)
default:
return nil
}

for _, service := range appProto.Services {
webConfig := service.GetWebConfig()
if webConfig != nil {
domains := webConfig.GetDomains()

if len(domains) > 0 {
body = fmt.Sprintf("%s\n\n**Preview URL**: https://%s", body, domains[0].Name)
}
}
}

_, _, err = client.Issues.CreateComment(
ctx,
repoDetails[0],
repoDetails[1],
inp.prNumber,
&github.IssueComment{
Body: pointer.String(body),
},
)
if err != nil {
return telemetry.Error(ctx, span, err, "error creating github comment")
}

return nil
}
29 changes: 29 additions & 0 deletions api/server/router/porter_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,35 @@ func getPorterAppRoutes(
Router: r,
})

// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/status -> porter_app.NewReportRevisionStatusHandler
reportRevisionStatusEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Verb: types.APIVerbUpdate,
Method: types.HTTPVerbPost,
Path: &types.Path{
Parent: basePath,
RelativePath: fmt.Sprintf("/apps/{%s}/revisions/{%s}/status", types.URLParamPorterAppName, types.URLParamAppRevisionID),
},
Scopes: []types.PermissionScope{
types.UserScope,
types.ProjectScope,
types.ClusterScope,
},
},
)

reportRevisionStatusHandler := porter_app.NewReportRevisionStatusHandler(
config,
factory.GetDecoderValidator(),
factory.GetResultWriter(),
)

routes = append(routes, &router.Route{
Endpoint: reportRevisionStatusEndpoint,
Handler: reportRevisionStatusHandler,
Router: r,
})

// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/update-environment -> porter_app.NewUpdateAppEnvironmentHandler
updateAppEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Expand Down
Loading

0 comments on commit 96d5360

Please sign in to comment.