Просмотр исходного кода

POR-1435 create app templates and commit preview env workflow (#3715)

ianedwards 2 лет назад
Родитель
Сommit
a916d380a7

+ 66 - 1
api/server/handlers/porter_app/create_app_template.go

@@ -1,10 +1,14 @@
 package porter_app
 
 import (
+	"context"
+	"encoding/base64"
 	"net/http"
 	"time"
 
 	"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/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -12,6 +16,7 @@ import (
 	"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/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/porter_app"
@@ -135,11 +140,19 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		appTemplate = &models.AppTemplate{
 			ProjectID:   int(project.ID),
 			PorterAppID: int(porterApps[0].ID),
-			Base64App:   request.B64AppProto,
 		}
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "update-app-template", Value: false})
 	}
 
+	protoWithoutDefaultAppEnvGroups, err := filterDefaultAppEnvGroups(ctx, request.B64AppProto, agent)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error filtering default app env groups")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	appTemplate.Base64App = protoWithoutDefaultAppEnvGroups
+
 	updatedAppTemplate, err := c.Repo().AppTemplate().CreateAppTemplate(appTemplate)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error creating app template")
@@ -200,3 +213,55 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	c.WriteResult(w, r, res)
 }
+
+// filterDefaultAppEnvGroups filters out any default app env groups found when creating an app template
+// app templates are based on the latest version of a given app, so it is possible for this env group to be included
+// however, the app template will get its own default env group when used to deploy to a preview environment
+func filterDefaultAppEnvGroups(ctx context.Context, b64AppProto string, agent *kubernetes.Agent) (string, error) {
+	ctx, span := telemetry.NewSpan(ctx, "filter-default-app-env-groups")
+	defer span.End()
+
+	var finalAppProto string
+
+	if b64AppProto == "" {
+		return finalAppProto, telemetry.Error(ctx, span, nil, "b64 app proto is empty")
+	}
+	if agent == nil {
+		return finalAppProto, telemetry.Error(ctx, span, nil, "agent is nil")
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(b64AppProto)
+	if err != nil {
+		return finalAppProto, telemetry.Error(ctx, span, err, "error decoding base app")
+	}
+
+	appProto := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, appProto)
+	if err != nil {
+		return finalAppProto, telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+	}
+
+	filteredEnvGroups := []*porterv1.EnvGroup{}
+	for _, envGroup := range appProto.EnvGroups {
+		baseEnvGroup, err := environment_groups.LatestBaseEnvironmentGroup(ctx, agent, envGroup.Name)
+		if err != nil {
+			return finalAppProto, telemetry.Error(ctx, span, err, "unable to get latest base environment group")
+		}
+		if baseEnvGroup.DefaultAppEnvironment {
+			continue
+		}
+
+		filteredEnvGroups = append(filteredEnvGroups, envGroup)
+	}
+
+	appProto.EnvGroups = filteredEnvGroups
+
+	encoded, err := helpers.MarshalContractObject(ctx, appProto)
+	if err != nil {
+		return finalAppProto, telemetry.Error(ctx, span, err, "error marshalling app proto")
+	}
+
+	finalAppProto = base64.StdEncoding.EncodeToString(encoded)
+
+	return finalAppProto, nil
+}

+ 42 - 15
api/server/handlers/porter_app/create_secret_and_open_pr.go

@@ -54,6 +54,17 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if request.Branch == "" {
+		err := telemetry.Error(ctx, span, nil, "branch cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.PreviewsWorkflowFilename != "" && request.DeleteWorkflowFilename != "" {
+		err := telemetry.Error(ctx, span, nil, "both preview and delete workflow filenames cannot be set")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
 	client, err := getGithubClient(c.Config(), request.GithubAppInstallationID)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error creating github client")
@@ -100,24 +111,40 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	var prRequestBody string
 	if request.DeleteWorkflowFilename == "" {
 		prRequestBody = "Hello 👋 from Porter! Please merge this PR to finish setting up your application."
-	} else {
+	} else if request.PreviewsWorkflowFilename == "" {
 		prRequestBody = "Please merge this PR to delete the workflow file associated with your application."
+	} else {
+		prRequestBody = "Hello 👋 from Porter! Please merge this PR to enable preview environments for your application."
 	}
+
 	if request.OpenPr || request.DeleteWorkflowFilename != "" {
-		pr, err = actions.OpenGithubPR(&actions.GithubPROpts{
-			Client:                 client,
-			GitRepoOwner:           request.GithubRepoOwner,
-			GitRepoName:            request.GithubRepoName,
-			StackName:              appName,
-			ProjectID:              project.ID,
-			ClusterID:              cluster.ID,
-			ServerURL:              c.Config().ServerConf.ServerURL,
-			DefaultBranch:          request.Branch,
-			SecretName:             secretName,
-			PorterYamlPath:         request.PorterYamlPath,
-			Body:                   prRequestBody,
-			DeleteWorkflowFilename: request.DeleteWorkflowFilename,
-		})
+		openPRInput := &actions.GithubPROpts{
+			PRAction:       actions.GithubPRAction_NewAppWorkflow,
+			Client:         client,
+			GitRepoOwner:   request.GithubRepoOwner,
+			GitRepoName:    request.GithubRepoName,
+			StackName:      appName,
+			ProjectID:      project.ID,
+			ClusterID:      cluster.ID,
+			ServerURL:      c.Config().ServerConf.ServerURL,
+			DefaultBranch:  request.Branch,
+			SecretName:     secretName,
+			PorterYamlPath: request.PorterYamlPath,
+			Body:           prRequestBody,
+			PRBranch:       "porter-stack",
+		}
+		if request.DeleteWorkflowFilename != "" {
+			openPRInput.PRAction = actions.GithubPRAction_DeleteAppWorkflow
+			openPRInput.WorkflowFileName = request.DeleteWorkflowFilename
+			openPRInput.PRBranch = "porter-stack-delete"
+		}
+		if request.PreviewsWorkflowFilename != "" {
+			openPRInput.PRAction = actions.GithubPRAction_PreviewAppWorkflow
+			openPRInput.WorkflowFileName = request.PreviewsWorkflowFilename
+			openPRInput.PRBranch = "porter-stack-preview"
+		}
+
+		pr, err = actions.OpenGithubPR(openPRInput)
 	}
 
 	if err != nil {

+ 1 - 1
api/server/handlers/porter_app/validate.go

@@ -99,7 +99,7 @@ func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	} else {
 		decoded, err := base64.StdEncoding.DecodeString(request.Base64AppProto)
 		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error decoding base  yaml")
+			err := telemetry.Error(ctx, span, err, "error decoding base yaml")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 			return
 		}

+ 8 - 7
api/types/stack.go

@@ -12,13 +12,14 @@ type ImageInfo struct {
 }
 
 type CreateSecretAndOpenGHPRRequest struct {
-	GithubAppInstallationID int64  `json:"github_app_installation_id" form:"required"`
-	GithubRepoOwner         string `json:"github_repo_owner" form:"required"`
-	GithubRepoName          string `json:"github_repo_name" form:"required"`
-	OpenPr                  bool   `json:"open_pr"`
-	Branch                  string `json:"branch"`
-	PorterYamlPath          string `json:"porter_yaml_path"`
-	DeleteWorkflowFilename  string `json:"delete_workflow_filename"`
+	GithubAppInstallationID  int64  `json:"github_app_installation_id" form:"required"`
+	GithubRepoOwner          string `json:"github_repo_owner" form:"required"`
+	GithubRepoName           string `json:"github_repo_name" form:"required"`
+	OpenPr                   bool   `json:"open_pr"`
+	Branch                   string `json:"branch"`
+	PorterYamlPath           string `json:"porter_yaml_path"`
+	DeleteWorkflowFilename   string `json:"delete_workflow_filename"`
+	PreviewsWorkflowFilename string `json:"previews_workflow_filename"`
 }
 
 type CreateSecretAndOpenGHPRResponse struct {

+ 81 - 33
dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx

@@ -1,6 +1,6 @@
 import { RouteComponentProps, withRouter } from "react-router";
 import styled from "styled-components";
-import React from "react";
+import React, { useMemo } from "react";
 
 import Modal from "components/porter/Modal";
 import Text from "components/porter/Text";
@@ -9,7 +9,7 @@ import ExpandableSection from "components/porter/ExpandableSection";
 import Button from "components/porter/Button";
 import Select from "components/porter/Select";
 import api from "shared/api";
-import { getGithubAction } from "./utils";
+import { getGithubAction, getPreviewGithubAction } from "./utils";
 import YamlEditor from "components/YamlEditor";
 import Error from "components/porter/Error";
 import Checkbox from "components/porter/Checkbox";
@@ -26,7 +26,8 @@ type Props = RouteComponentProps & {
   deployPorterApp?: () => Promise<boolean>;
   deploymentError?: string;
   porterYamlPath?: string;
-}
+  type?: "create" | "preview";
+};
 
 type Choice = "open_pr" | "copy";
 
@@ -42,16 +43,47 @@ const GithubActionModal: React.FC<Props> = ({
   deployPorterApp,
   deploymentError,
   porterYamlPath,
+  type = "create",
   ...props
 }) => {
   const [choice, setChoice] = React.useState<Choice>("open_pr");
   const [loading, setLoading] = React.useState<boolean>(false);
   const [isChecked, setIsChecked] = React.useState<boolean>(false);
 
+  const actionYamlContents = useMemo(() => {
+    if (!projectId || !clusterId || !stackName || !branch || !porterYamlPath) {
+      return "";
+    }
+    if (type === "preview") {
+      return getPreviewGithubAction(
+        projectId,
+        clusterId,
+        stackName,
+        porterYamlPath
+      );
+    }
+
+    return getGithubAction(
+      projectId,
+      clusterId,
+      stackName,
+      branch,
+      porterYamlPath
+    );
+  }, [type]);
+
   const submit = async () => {
-    if (githubAppInstallationID && githubRepoOwner && githubRepoName && branch && stackName && projectId && clusterId) {
+    if (
+      githubAppInstallationID &&
+      githubRepoOwner &&
+      githubRepoName &&
+      branch &&
+      stackName &&
+      projectId &&
+      clusterId
+    ) {
       try {
-        setLoading(true)
+        setLoading(true);
         // this creates the dummy chart
         var success = true;
         if (deployPorterApp) {
@@ -67,8 +99,11 @@ const GithubActionModal: React.FC<Props> = ({
               github_repo_owner: githubRepoOwner,
               github_repo_name: githubRepoName,
               branch,
-              open_pr: (choice === "open_pr" || isChecked),
+              open_pr: choice === "open_pr" || isChecked,
               porter_yaml_path: porterYamlPath,
+              ...(type === "preview" && {
+                previews_workflow_filename: `.github/workflows/porter_preview_${stackName}.yml`,
+              }),
             },
             {
               project_id: projectId,
@@ -85,37 +120,34 @@ const GithubActionModal: React.FC<Props> = ({
           props.history.push(`/apps/${stackName}`);
         }
       } catch (error) {
-        console.log(error)
       } finally {
-        setLoading(false)
+        setLoading(false);
       }
     } else {
       console.log("missing information");
     }
-  }
+  };
   return (
     <Modal closeModal={closeModal}>
-      <Text size={16}>
-        Continuous Integration (CI) with GitHub Actions
-      </Text>
+      <Text size={16}>Continuous Integration (CI) with GitHub Actions</Text>
       <Spacer height="15px" />
       <Text color="helper">
-        In order to automatically update your services every time new code is pushed to your GitHub branch, the following file must exist in your GitHub repository:
+        In order to automatically update your services every time new code is
+        pushed to your GitHub branch, the following file must exist in your
+        GitHub repository:
       </Text>
       <Spacer y={0.5} />
       <ExpandableSection
         noWrapper
         expandText="[+] Show code"
         collapseText="[-] Hide code"
-        Header={
-          <ModalHeader>.github/workflows/porter.yml</ModalHeader>
-        }
+        Header={<ModalHeader>.github/workflows/porter.yml</ModalHeader>}
         isInitiallyExpanded
         spaced
-        copy={getGithubAction(projectId, clusterId, stackName, branch, porterYamlPath)}
+        copy={actionYamlContents}
         ExpandedSection={
           <YamlEditor
-            value={getGithubAction(projectId, clusterId, stackName, branch, porterYamlPath)}
+            value={actionYamlContents}
             readOnly={true}
             height="300px"
           />
@@ -123,15 +155,25 @@ const GithubActionModal: React.FC<Props> = ({
       />
       <Spacer y={1} />
       <Text color="helper">
-        Porter can open a PR for you to approve and merge this file into your repository, or you can add it yourself. If you allow Porter to open a PR, you will be redirected to the PR in a new tab after submitting below.
+        Porter can open a PR for you to approve and merge this file into your
+        repository, or you can add it yourself. If you allow Porter to open a
+        PR, you will be redirected to the PR in a new tab after submitting
+        below.
       </Text>
       <Spacer y={1} />
       {deployPorterApp ? (
         <>
           <Select
             options={[
-              { label: "I authorize Porter to open a PR on my behalf (recommended)", value: "open_pr" },
-              { label: "I will copy the file into my repository myself", value: "copy" },
+              {
+                label:
+                  "I authorize Porter to open a PR on my behalf (recommended)",
+                value: "open_pr",
+              },
+              {
+                label: "I will copy the file into my repository myself",
+                value: "copy",
+              },
             ]}
             setValue={(x: string) => setChoice(x as Choice)}
             width="100%"
@@ -141,9 +183,13 @@ const GithubActionModal: React.FC<Props> = ({
             onClick={submit}
             width={"110px"}
             loadingText={"Submitting..."}
-            status={loading ? "loading" : deploymentError ? (
-              <Error message={deploymentError} />
-            ) : undefined}
+            status={
+              loading ? (
+                "loading"
+              ) : deploymentError ? (
+                <Error message={deploymentError} />
+              ) : undefined
+            }
           >
             Deploy app
           </Button>
@@ -154,26 +200,28 @@ const GithubActionModal: React.FC<Props> = ({
             checked={isChecked}
             toggleChecked={() => setIsChecked(!isChecked)}
           >
-            <Text>
-              I authorize Porter to open a PR on my behalf
-            </Text>
+            <Text>I authorize Porter to open a PR on my behalf</Text>
           </Checkbox>
           <Spacer y={1} />
           <Button
             disabled={!isChecked}
             onClick={submit}
             loadingText={"Submitting..."}
-            status={loading ? "loading" : deploymentError ? (
-              <Error message={deploymentError} />
-            ) : undefined}
+            status={
+              loading ? (
+                "loading"
+              ) : deploymentError ? (
+                <Error message={deploymentError} />
+              ) : undefined
+            }
           >
             Open a PR for me
           </Button>
         </>
       )}
     </Modal>
-  )
-}
+  );
+};
 
 export default withRouter(GithubActionModal);
 
@@ -184,4 +232,4 @@ const ModalHeader = styled.div`
   height: 40px;
   display: flex;
   align-items: center;
-`;
+`;

+ 52 - 3
dashboard/src/main/home/app-dashboard/new-app-flow/utils.ts

@@ -2,7 +2,12 @@ export const overrideObjectValues = (obj1: any, obj2: any) => {
   // Iterate over the keys in obj2
   for (const key in obj2) {
     // Check if the key exists in obj1 and if its value is an object
-    if (key in obj1 && obj1[key] !== null && typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
+    if (
+      key in obj1 &&
+      obj1[key] !== null &&
+      typeof obj1[key] === "object" &&
+      typeof obj2[key] === "object"
+    ) {
       obj1[key] = overrideObjectValues(obj1[key], obj2[key]);
     } else {
       obj1[key] = obj2[key];
@@ -13,7 +18,13 @@ export const overrideObjectValues = (obj1: any, obj2: any) => {
   return obj1;
 };
 
-export const getGithubAction = (projectID: number, clusterId: number, stackName: string, branchName: string, porterYamlPath: string = "porter.yaml") => {
+export const getGithubAction = (
+  projectID: number,
+  clusterId: number,
+  stackName: string,
+  branchName: string,
+  porterYamlPath: string = "porter.yaml"
+) => {
   return `on:
   push:
     branches:
@@ -40,4 +51,42 @@ jobs:
         PORTER_STACK_NAME: ${stackName}
         PORTER_TAG: \${{ steps.vars.outputs.sha_short }}
         PORTER_TOKEN: \${{ secrets.PORTER_STACK_${projectID}_${clusterId} }}`;
-}
+};
+
+export const getPreviewGithubAction = (
+  projectID: number,
+  clusterId: number,
+  stackName: string,
+  porterYamlPath: string = "porter.yaml"
+) => {
+  return `on:
+  pull_request:
+    branches:
+    - '!porter-**'
+    types:
+    - opened
+    - synchronize
+    
+name: Deploy preview environment
+jobs:
+  porter-deploy:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v3
+    - name: Set Github tag
+      id: vars
+      run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+    - name: Build and deploy preview environment
+      timeout-minutes: 30
+      uses: porter-dev/porter-cli-action@v0.1.0
+      with:
+        command: apply -f ${porterYamlPath} --preview
+      env:
+        PORTER_CLUSTER: ${clusterId}
+        PORTER_HOST: https://dashboard.getporter.dev
+        PORTER_PROJECT: ${projectID}
+        PORTER_STACK_NAME: ${stackName}
+        PORTER_TAG: \${{ steps.vars.outputs.sha_short }}
+        PORTER_TOKEN: \${{ secrets.PORTER_STACK_${projectID}_${clusterId} }}`;
+};

+ 92 - 7
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
 import { FormProvider, useForm } from "react-hook-form";
 
 import VerticalSteps from "components/porter/VerticalSteps";
@@ -7,6 +7,7 @@ import {
   SourceOptions,
   applyPreviewOverrides,
   clientAppFromProto,
+  clientAppToProto,
   porterAppFormValidator,
 } from "lib/porter-apps";
 import {
@@ -28,14 +29,15 @@ import Button from "components/porter/Button";
 import { useAppValidation } from "lib/hooks/useAppValidation";
 import { PorterApp } from "@porter-dev/api-contracts";
 import axios from "axios";
+import GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
 
 const AppTemplateForm: React.FC = () => {
   const [step, setStep] = useState(0);
   const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
     null
   );
-  const [isCreating, setIsCreating] = useState(false);
   const [createError, setCreateError] = useState("");
+  const [showGHAModal, setShowGHAModal] = useState(false);
   const [{ variables, secrets }, setFinalizedAppEnv] = useState<{
     variables: Record<string, string>;
     secrets: Record<string, string>;
@@ -132,12 +134,28 @@ const AppTemplateForm: React.FC = () => {
   const onSubmit = handleSubmit(async (data) => {
     try {
       setCreateError("");
-      const { validatedAppProto, variables, secrets } = await validateApp(data);
-      setValidatedAppProto(validatedAppProto);
+
+      const proto = clientAppToProto(data);
+      setValidatedAppProto(proto);
+
+      const { env } = data.app;
+      const variables = env
+        .filter((e) => !e.hidden && !e.deleted)
+        .reduce((acc: Record<string, string>, item) => {
+          acc[item.key] = item.value;
+          return acc;
+        }, {});
+      const secrets = env
+        .filter((e) => !e.deleted)
+        .reduce((acc: Record<string, string>, item) => {
+          if (item.hidden) {
+            acc[item.key] = item.value;
+          }
+          return acc;
+        }, {});
       setFinalizedAppEnv({ variables, secrets });
 
-      // todo(ianedwards): this is essentially a no-op for now
-      // follow up will be to actually create the template and commit the workflow
+      setShowGHAModal(true);
     } catch (err) {
       if (axios.isAxiosError(err) && err.response?.data?.error) {
         setCreateError(err.response?.data?.error);
@@ -149,6 +167,51 @@ const AppTemplateForm: React.FC = () => {
     }
   });
 
+  const createTemplateAndWorkflow = useCallback(
+    async ({
+      app,
+      variables,
+      secrets,
+    }: {
+      app: PorterApp | null;
+      variables: Record<string, string>;
+      secrets: Record<string, string>;
+    }) => {
+      try {
+        if (!app) {
+          return false;
+        }
+
+        await api.createAppTemplate(
+          "<token>",
+          {
+            b64_app_proto: btoa(app.toJsonString()),
+            variables,
+            secrets,
+          },
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+            porter_app_name: porterApp.name,
+          }
+        );
+
+        return true;
+      } catch (err) {
+        if (axios.isAxiosError(err) && err.response?.data?.error) {
+          setCreateError(err.response?.data?.error);
+          return false;
+        }
+
+        setCreateError(
+          "An error occurred while creating the CI workflow. Please try again."
+        );
+        return false;
+      }
+    },
+    []
+  );
+
   useEffect(() => {
     reset({
       app: withPreviewOverrides,
@@ -169,7 +232,7 @@ const AppTemplateForm: React.FC = () => {
     <FormProvider {...porterAppFormMethods}>
       <form onSubmit={onSubmit}>
         <VerticalSteps
-          currentStep={step}
+          currentStep={3}
           steps={[
             <>
               <Text size={16}>Application services</Text>
@@ -217,6 +280,28 @@ const AppTemplateForm: React.FC = () => {
           ].filter((x) => x)}
         />
       </form>
+      {showGHAModal && (
+        <GithubActionModal
+          type="preview"
+          closeModal={() => setShowGHAModal(false)}
+          githubAppInstallationID={latestSource.git_repo_id}
+          githubRepoOwner={latestSource.git_repo_name.split("/")[0]}
+          githubRepoName={latestSource.git_repo_name.split("/")[1]}
+          branch={latestSource.git_branch}
+          stackName={porterApp.name}
+          projectId={projectId}
+          clusterId={clusterId}
+          deployPorterApp={() =>
+            createTemplateAndWorkflow({
+              app: validatedAppProto,
+              variables,
+              secrets,
+            })
+          }
+          deploymentError={createError}
+          porterYamlPath={latestSource.porter_yaml_path}
+        />
+      )}
     </FormProvider>
   );
 };

+ 16 - 0
dashboard/src/shared/api.tsx

@@ -937,6 +937,20 @@ const createApp = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/create`;
 });
 
+const createAppTemplate = baseApi<
+{
+  b64_app_proto: string;
+  variables: Record<string, string>
+  secrets: Record<string, string>
+},
+{
+  project_id: number;
+  cluster_id: number;
+  porter_app_name: string;
+}>("POST", ({ project_id, cluster_id, porter_app_name}) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
+})
+
 const applyApp = baseApi<
   {
     deployment_target_id: string;
@@ -3001,6 +3015,7 @@ const createSecretAndOpenGitHubPullRequest = baseApi<
     open_pr?: boolean;
     porter_yaml_path?: string;
     delete_workflow_filename?: string;
+    previews_workflow_filename?: string;
   },
   {
     project_id: number;
@@ -3144,6 +3159,7 @@ export default {
   getBranchHead,
   validatePorterApp,
   createApp,
+  createAppTemplate,
   applyApp,
   getAttachedEnvGroups,
   getLatestRevision,

+ 11 - 0
internal/integrations/ci/actions/actions.go

@@ -224,6 +224,17 @@ type GithubActionYAMLOnPush struct {
 	Push GithubActionYAMLOnPushBranches `yaml:"push,omitempty"`
 }
 
+// GithubActionYAMLOnPullRequest is a struct that represents the "on" field of a Github Action YAML file for pull request events
+type GithubActionYAMLOnPullRequest struct {
+	PullRequest GithubActionYAMLOnPullRequestTypes `yaml:"pull_request,omitempty"`
+}
+
+// GithubActionYAMLOnPullRequestTypes is a struct that represents the "types" field of a Github Action YAML file for pull request events
+type GithubActionYAMLOnPullRequestTypes struct {
+	Branches []string `yaml:"branches,omitempty"`
+	Types    []string `yaml:"types,omitempty"`
+}
+
 type GithubActionYAMLJob struct {
 	RunsOn      string                 `yaml:"runs-on,omitempty"`
 	Steps       []GithubActionYAMLStep `yaml:"steps,omitempty"`

+ 120 - 41
internal/integrations/ci/actions/stack.go

@@ -9,7 +9,20 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
+// GithubPRAction is an action to take when opening a PR
+type GithubPRAction string
+
+const (
+	// GithubPRAction_NewAppWorkflow is the action for creating a workflow for a new application
+	GithubPRAction_NewAppWorkflow GithubPRAction = "new-app-workflow"
+	// GithubPRAction_DeleteAppWorkflow is the action for deleting an application workflow
+	GithubPRAction_DeleteAppWorkflow GithubPRAction = "delete-app-workflow"
+	// GithubPRAction_PreviewAppWorkflow is the action for creating the preview app workflow
+	GithubPRAction_PreviewAppWorkflow GithubPRAction = "preview-app-workflow"
+)
+
 type GithubPROpts struct {
+	PRAction                  GithubPRAction
 	Client                    *github.Client
 	GitRepoOwner, GitRepoName string
 	ApplyWorkflowYAML         string
@@ -20,7 +33,8 @@ type GithubPROpts struct {
 	SecretName                string
 	PorterYamlPath            string
 	Body                      string
-	DeleteWorkflowFilename    string
+	WorkflowFileName          string
+	PRBranch                  string
 }
 
 type GetStackApplyActionYAMLOpts struct {
@@ -30,31 +44,65 @@ type GetStackApplyActionYAMLOpts struct {
 	DefaultBranch        string
 	SecretName           string
 	PorterYamlPath       string
+	Preview              bool
 }
 
 func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 	var pr *github.PullRequest
-	var prBranchName string
-	if opts.DeleteWorkflowFilename != "" {
-		prBranchName = "porter-stack-delete"
-	} else {
-		prBranchName = "porter-stack"
+
+	if opts == nil {
+		return pr, fmt.Errorf("input options cannot be nil")
 	}
 
 	err := createNewBranch(opts.Client,
 		opts.GitRepoOwner,
 		opts.GitRepoName,
 		opts.DefaultBranch,
-		prBranchName,
+		opts.PRBranch,
+	)
+	if err != nil {
+		return pr, fmt.Errorf("error creating branch: %w", err)
+	}
+
+	err = commitChange(opts.PRBranch, *opts)
+	if err != nil {
+		return pr, fmt.Errorf("error committing change: %w", err)
+	}
+
+	prTitle := getPRTitle(opts.PRAction, opts.StackName)
+	pr, _, err = opts.Client.PullRequests.Create(
+		context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
+			Title: github.String(prTitle),
+			Base:  github.String(opts.DefaultBranch),
+			Head:  github.String(opts.PRBranch),
+			Body:  github.String(opts.Body),
+		},
 	)
 	if err != nil {
 		return pr, fmt.Errorf(
-			"error creating branch: %w",
+			"error creating PR: %w",
 			err,
 		)
 	}
+	return pr, nil
+}
+
+func getPRTitle(action GithubPRAction, stackName string) string {
+	switch action {
+	case GithubPRAction_NewAppWorkflow:
+		return fmt.Sprintf("Enable Porter Application %s", stackName)
+	case GithubPRAction_DeleteAppWorkflow:
+		return fmt.Sprintf("Delete Porter Application %s", stackName)
+	case GithubPRAction_PreviewAppWorkflow:
+		return "Enable Preview Environments on Porter"
+	default:
+		return ""
+	}
+}
 
-	if opts.DeleteWorkflowFilename == "" {
+func commitChange(prBranchName string, opts GithubPROpts) error {
+	switch opts.PRAction {
+	case GithubPRAction_NewAppWorkflow:
 		applyWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
 			ServerURL:      opts.ServerURL,
 			ClusterID:      opts.ClusterID,
@@ -63,10 +111,12 @@ func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 			DefaultBranch:  opts.DefaultBranch,
 			SecretName:     opts.SecretName,
 			PorterYamlPath: opts.PorterYamlPath,
+			Preview:        false,
 		})
 		if err != nil {
-			return pr, err
+			return err
 		}
+
 		_, err = commitWorkflowFile(
 			opts.Client,
 			fmt.Sprintf("porter_stack_%s.yml", strings.ToLower(opts.StackName)),
@@ -74,50 +124,53 @@ func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 			opts.GitRepoName, prBranchName, false,
 		)
 		if err != nil {
-			return pr, fmt.Errorf(
-				"error committing file: %w",
-				err,
-			)
+			return fmt.Errorf("error committing file: %w", err)
 		}
-	} else {
-		err = deleteGithubFile(
+
+		return nil
+	case GithubPRAction_DeleteAppWorkflow:
+		err := deleteGithubFile(
 			opts.Client,
-			opts.DeleteWorkflowFilename,
+			opts.WorkflowFileName,
 			opts.GitRepoOwner,
 			opts.GitRepoName,
 			prBranchName,
 			false,
 		)
 		if err != nil {
-			return pr, fmt.Errorf(
-				"error committing deletion: %w",
-				err,
-			)
+			return fmt.Errorf("error committing deletion: %w", err)
 		}
 
-	}
+		return nil
+	case GithubPRAction_PreviewAppWorkflow:
+		previewWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
+			ServerURL:      opts.ServerURL,
+			ClusterID:      opts.ClusterID,
+			ProjectID:      opts.ProjectID,
+			StackName:      opts.StackName,
+			DefaultBranch:  opts.DefaultBranch,
+			SecretName:     opts.SecretName,
+			PorterYamlPath: opts.PorterYamlPath,
+			Preview:        true,
+		})
+		if err != nil {
+			return err
+		}
 
-	var prTitle string
-	if opts.DeleteWorkflowFilename != "" {
-		prTitle = fmt.Sprintf("Delete Porter Application %s", opts.StackName)
-	} else {
-		prTitle = fmt.Sprintf("Enable Porter Application %s", opts.StackName)
-	}
-	pr, _, err = opts.Client.PullRequests.Create(
-		context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
-			Title: github.String(prTitle),
-			Base:  github.String(opts.DefaultBranch),
-			Head:  github.String(prBranchName),
-			Body:  github.String(opts.Body),
-		},
-	)
-	if err != nil {
-		return pr, fmt.Errorf(
-			"error creating PR: %w",
-			err,
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_preview_%s.yml", strings.ToLower(opts.StackName)),
+			previewWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, prBranchName, false,
 		)
+		if err != nil {
+			return fmt.Errorf("error committing file: %w", err)
+		}
+
+		return nil
+	default:
+		return fmt.Errorf("invalid PR action: %s", opts.PRAction)
 	}
-	return pr, nil
 }
 
 func getStackApplyActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error) {
@@ -132,9 +185,35 @@ func getStackApplyActionYAML(opts *GetStackApplyActionYAMLOpts) ([]byte, error)
 			opts.PorterYamlPath,
 			opts.ProjectID,
 			opts.ClusterID,
+			opts.Preview,
 		),
 	}
 
+	if opts.Preview {
+		actionYaml := GithubActionYAML{
+			On: GithubActionYAMLOnPullRequest{
+				PullRequest: GithubActionYAMLOnPullRequestTypes{
+					Branches: []string{
+						"!porter-**",
+					},
+					Types: []string{
+						"opened",
+						"synchronize",
+					},
+				},
+			},
+			Name: "Deploy to Preview Environment",
+			Jobs: map[string]GithubActionYAMLJob{
+				"porter-deploy": {
+					RunsOn: "ubuntu-latest",
+					Steps:  gaSteps,
+				},
+			},
+		}
+
+		return yaml.Marshal(actionYaml)
+	}
+
 	actionYAML := GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{

+ 14 - 2
internal/integrations/ci/actions/steps.go

@@ -74,6 +74,7 @@ func getCreatePreviewEnvStep(
 func getDeployStackStep(
 	serverURL, porterTokenSecretName, stackName, actionVersion, porterYamlPath string,
 	projectID, clusterID uint,
+	preview bool,
 ) GithubActionYAMLStep {
 	var path string
 	if porterYamlPath != "" {
@@ -81,11 +82,22 @@ func getDeployStackStep(
 	} else {
 		path = "porter.yaml"
 	}
+
+	command := fmt.Sprintf("apply -f %s", path)
+	if preview {
+		command = fmt.Sprintf("%s --preview", command)
+	}
+
+	name := "Deploy stack"
+	if preview {
+		name = "Build and deploy preview environment"
+	}
+
 	return GithubActionYAMLStep{
-		Name: "Deploy stack",
+		Name: name,
 		Uses: fmt.Sprintf("%s@%s", cliActionName, actionVersion),
 		With: map[string]string{
-			"command": fmt.Sprintf("apply -f %s", path),
+			"command": command,
 		},
 		Env: map[string]string{
 			"PORTER_CLUSTER":    fmt.Sprintf("%d", clusterID),

+ 4 - 0
internal/kubernetes/environment_groups/list.go

@@ -41,6 +41,8 @@ type EnvironmentGroup struct {
 	SecretVariables map[string]string `json:"secret_variables,omitempty"`
 	// CreatedAt is only used for display purposes and is in UTC Unix time
 	CreatedAtUTC time.Time `json:"created_at,omitempty"`
+	// DefaultAppEnvironment is a boolean value that determines whether or not this environment group is the default environment group for an app
+	DefaultAppEnvironment bool `json:"default_app_environment"`
 }
 
 type environmentGroupOptions struct {
@@ -154,6 +156,7 @@ func listEnvironmentGroups(ctx context.Context, a *kubernetes.Agent, listOpts ..
 			Variables:       cm.Data,
 			SecretVariables: envGroupSet[cm.Name].SecretVariables,
 			CreatedAtUTC:    cm.CreationTimestamp.Time.UTC(),
+			DefaultAppEnvironment: cm.Labels[LabelKey_DefaultAppEnvironment] == "true",
 		}
 	}
 
@@ -192,6 +195,7 @@ func listEnvironmentGroups(ctx context.Context, a *kubernetes.Agent, listOpts ..
 			SecretVariables: stringSecret,
 			Variables:       envGroupSet[secret.Name].Variables,
 			CreatedAtUTC:    secret.CreationTimestamp.Time.UTC(),
+			DefaultAppEnvironment: secret.Labels[LabelKey_DefaultAppEnvironment] == "true",
 		}
 	}
 

+ 1 - 1
internal/repository/gorm/app_template.go

@@ -57,7 +57,7 @@ func (repo *AppTemplateRepository) CreateAppTemplate(appTemplate *models.AppTemp
 		appTemplate.UpdatedAt = time.Now().UTC()
 	}
 
-	if err := repo.db.Create(appTemplate).Error; err != nil {
+	if err := repo.db.Save(appTemplate).Error; err != nil {
 		return nil, err
 	}