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