소스 검색

Merge remote-tracking branch 'origin/master' into feat/env-creation-apply-step

Ian Edwards 2 년 전
부모
커밋
d4197eb7ab
37개의 변경된 파일517개의 추가작업 그리고 347개의 파일을 삭제
  1. 0 99
      .github/workflows/old_production.yaml
  2. 1 0
      .github/workflows/porter_stack_porter-ui.yml
  3. 1 0
      .github/workflows/pr_push_checks.yaml
  4. 2 2
      .github/workflows/prerelease.yaml
  5. 1 0
      .github/workflows/production.yml
  6. 38 9
      api/server/handlers/porter_app/analytics.go
  7. 37 5
      api/server/handlers/porter_app/create_and_update_events.go
  8. 5 7
      api/server/handlers/porter_app/list_events.go
  9. 1 0
      api/server/handlers/project/create.go
  10. 1 0
      api/server/handlers/project/create_test.go
  11. 2 0
      api/types/project.go
  12. 43 41
      cli/cmd/stack/apply.go
  13. 41 8
      cli/cmd/stack/hooks.go
  14. 3 2
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx
  15. 1 81
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx
  16. 1 1
      dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx
  17. 36 32
      dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx
  18. 34 30
      dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx
  19. 40 1
      dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts
  20. 116 0
      dashboard/src/main/home/app-dashboard/new-app-flow/tabs/IngressCustomAnnotations.tsx
  21. 26 2
      dashboard/src/main/home/app-dashboard/new-app-flow/tabs/WebTabs.tsx
  22. 2 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  23. 15 0
      dashboard/src/shared/types.tsx
  24. 2 1
      docker/Dockerfile
  25. 2 1
      docker/cli.Dockerfile
  26. 2 1
      docker/dev.Dockerfile
  27. 2 1
      ee/docker/ee.Dockerfile
  28. 2 1
      ee/docker/provisioner.Dockerfile
  29. 8 6
      internal/analytics/track_events.go
  30. 38 8
      internal/analytics/tracks.go
  31. 2 0
      internal/models/project.go
  32. 2 1
      services/cli_install_script_container/Dockerfile
  33. 2 1
      services/deploy_init_container/Dockerfile
  34. 2 1
      services/migrator/Dockerfile
  35. 2 1
      services/porter_cli_container/dev.Dockerfile
  36. 2 1
      services/preview_env_setup_job/Dockerfile
  37. 2 1
      workers/Dockerfile

+ 0 - 99
.github/workflows/old_production.yaml

@@ -1,99 +0,0 @@
-name: Deploy to old production
-on:
-  push:
-    tags:
-      - production
-jobs:
-  deploy:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Set up Cloud SDK
-        uses: google-github-actions/setup-gcloud@v0
-        with:
-          project_id: ${{ secrets.GCP_PROJECT_ID }}
-          service_account_key: ${{ secrets.GCP_SA_KEY }}
-          export_default_credentials: true
-      - name: Configure AWS Credentials
-        uses: aws-actions/configure-aws-credentials@v1-node16
-        with:
-          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
-          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-          aws-region: ${{ secrets.AWS_REGION }}
-      - name: Install kubectl
-        uses: azure/setup-kubectl@v2.0
-        with:
-          version: "v1.19.15"
-      - name: Log in to gcloud CLI
-        run: gcloud auth configure-docker
-      - name: Checkout
-        uses: actions/checkout@v3
-      - name: Write Dashboard Environment Variables
-        run: |
-          cat >./dashboard/.env <<EOL
-          NODE_ENV=production
-          API_SERVER=dashboard.getporter.dev
-          COHERE_API_KEY=${{secrets.COHERE_API_KEY}}
-          DISCORD_KEY=${{secrets.DISCORD_KEY}}
-          DISCORD_CID=${{secrets.DISCORD_CID}}
-          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-          IS_HOSTED=true
-          ENABLE_COHERE=true
-          COHERE_API_KEY=${{secrets.COHERE_KEY}}
-          INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
-          INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
-          HOTJAR_ID=${{secrets.HOTJAR_ID}}
-          SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
-          SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
-          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
-          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
-          ENABLE_SENTRY=true
-          SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=frontend-production
-          ZAPIER_WEBHOOK_URL=${{secrets.ZAPIER_WEBHOOK_URL}}
-          DISCORD_WEBHOOK_URL=${{secrets.DISCORD_WEBHOOK_URL}}
-          EOL
-      - name: Build
-        run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./ee/docker/ee.Dockerfile --build-arg version=production
-      - name: Push
-        run: |
-          docker push gcr.io/porter-dev-273614/porter:latest
-      - name: Deploy to cluster
-        run: |
-          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
-            
-          kubectl rollout restart deployment/porter
-  deploy-provisioner:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Set up Cloud SDK
-        uses: google-github-actions/setup-gcloud@v0
-        with:
-          project_id: ${{ secrets.GCP_PROJECT_ID }}
-          service_account_key: ${{ secrets.GCP_SA_KEY }}
-          export_default_credentials: true
-      - name: Configure AWS Credentials
-        uses: aws-actions/configure-aws-credentials@v1-node16
-        with:
-          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
-          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-          aws-region: ${{ secrets.AWS_REGION }}
-      - name: Install kubectl
-        uses: azure/setup-kubectl@v2.0
-        with:
-          version: "v1.19.15"
-      - name: Log in to gcloud CLI
-        run: gcloud auth configure-docker
-      - name: Checkout
-        uses: actions/checkout@v3
-      - name: Build
-        run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/provisioner-service:latest -f ./ee/docker/provisioner.Dockerfile
-      - name: Push
-        run: |
-          docker push gcr.io/porter-dev-273614/provisioner-service:latest
-      - name: Deploy to cluster
-        run: |
-          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
-            
-          kubectl rollout restart deployment/provisioner

+ 1 - 0
.github/workflows/porter_stack_porter-ui.yml

@@ -22,6 +22,7 @@ jobs:
         with:
           go-version-file: go.mod
           cache: false
+          go-version: '1.20.5'
       - name: Download Go Modules
         run: go mod download
       - name: Build Server Binary

+ 1 - 0
.github/workflows/pr_push_checks.yaml

@@ -24,6 +24,7 @@ jobs:
         with:
           go-version-file: go.mod
           cache: false
+          go-version: '1.20.5'
       - name: Run Go vet
         run: go vet ./${{ matrix.folder }}/...
       - name: Run Go tests

+ 2 - 2
.github/workflows/prerelease.yaml

@@ -114,7 +114,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v3
         with:
-          go-version: 1.19
+          go-version: 1.20.5
       - name: Set up Node
         uses: actions/setup-node@v3
         with:
@@ -184,7 +184,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v3
         with:
-          go-version: 1.19
+          go-version: 1.20.5
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL

+ 1 - 0
.github/workflows/production.yml

@@ -22,6 +22,7 @@ jobs:
         with:
           go-version-file: go.mod
           cache: false
+          go-version: '1.20.5'
       - name: Download Go Modules
         run: go mod download
       - name: Build Server Binary

+ 38 - 9
api/server/handlers/porter_app/analytics.go

@@ -93,18 +93,47 @@ func (v *PorterAppAnalyticsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	v.WriteResult(w, r, user.ToUserType())
 }
 
-func TrackStackBuildFailure(
+func TrackStackBuildStatus(
 	config *config.Config,
 	user *models.User,
 	project *models.Project,
 	stackName string,
+	errorMessage string,
+	status string,
 ) error {
-	return config.AnalyticsClient.Track(analytics.StackBuildFailureTrack(&analytics.StackBuildFailureOpts{
-		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
-		StackName:              stackName,
-		Email:                  user.Email,
-		FirstName:              user.FirstName,
-		LastName:               user.LastName,
-		CompanyName:            user.CompanyName,
-	}))
+	if status == "PROGRESSING" {
+		return config.AnalyticsClient.Track(analytics.StackBuildProgressingTrack(&analytics.StackBuildOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
+			StackName:              stackName,
+			Email:                  user.Email,
+			FirstName:              user.FirstName,
+			LastName:               user.LastName,
+			CompanyName:            user.CompanyName,
+		}))
+	}
+
+	if status == "SUCCESS" {
+		return config.AnalyticsClient.Track(analytics.StackBuildSuccessTrack(&analytics.StackBuildOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
+			StackName:              stackName,
+			Email:                  user.Email,
+			FirstName:              user.FirstName,
+			LastName:               user.LastName,
+			CompanyName:            user.CompanyName,
+		}))
+	}
+
+	if status == "FAILED" {
+		return config.AnalyticsClient.Track(analytics.StackBuildFailureTrack(&analytics.StackBuildOpts{
+			ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, project.ID),
+			StackName:              stackName,
+			ErrorMessage:           errorMessage,
+			Email:                  user.Email,
+			FirstName:              user.FirstName,
+			LastName:               user.LastName,
+			CompanyName:            user.CompanyName,
+		}))
+	}
+
+	return nil
 }

+ 37 - 5
api/server/handlers/porter_app/create_and_update_events.go

@@ -2,7 +2,9 @@ package porter_app
 
 import (
 	"context"
+	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/google/uuid"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -36,10 +38,8 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 	defer span.End()
 
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
-		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
-	)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	request := &types.CreateOrUpdatePorterAppEventRequest{}
 	if ok := p.DecodeAndValidate(w, r, request); !ok {
@@ -56,12 +56,16 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 	}
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "porter-app-name", Value: stackName},
-		telemetry.AttributeKV{Key: "porter-app-event-type-id", Value: request.Type},
+		telemetry.AttributeKV{Key: "porter-app-event-type", Value: string(request.Type)},
 		telemetry.AttributeKV{Key: "porter-app-event-status", Value: request.Status},
 		telemetry.AttributeKV{Key: "porter-app-event-external-source", Value: request.TypeExternalSource},
 		telemetry.AttributeKV{Key: "porter-app-event-id", Value: request.ID},
 	)
 
+	if request.Type == types.PorterAppEventType_Build {
+		reportBuildStatus(ctx, request, p.Config(), user, project, stackName)
+	}
+
 	if request.ID == "" {
 		event, err := p.createNewAppEvent(ctx, *cluster, stackName, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
 		if err != nil {
@@ -82,6 +86,34 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 	p.WriteResult(w, r, event)
 }
 
+func reportBuildStatus(ctx context.Context, request *types.CreateOrUpdatePorterAppEventRequest, config *config.Config, user *models.User, project *models.Project, stackName string) {
+	ctx, span := telemetry.NewSpan(ctx, "report-build-status")
+	defer span.End()
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-build-status", Value: request.Status})
+
+	var errStr string
+	if errors, ok := request.Metadata["errors"]; ok {
+		if errs, ok := errors.(map[string]interface{}); ok {
+			errStringMap := make(map[string]string)
+			for k, v := range errs {
+				if valueStr, ok := v.(string); ok {
+					errStringMap[k] = valueStr
+				}
+			}
+
+			for k, v := range errStringMap {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: telemetry.AttributeKey(fmt.Sprintf("resource-%s", k)), Value: v})
+				errStr += k + ": " + v + ", "
+			}
+			errStr = strings.TrimSuffix(errStr, ", ")
+			_ = telemetry.Error(ctx, span, nil, errStr)
+		}
+	}
+
+	_ = TrackStackBuildStatus(config, user, project, stackName, errStr, request.Status)
+}
+
 // createNewAppEvent will create a new app event for the given porter app name. If the app event is an agent event, then it will be created only if there is no existing event which has the agent ID. In the case that an existing event is found, that will be returned instead
 func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status string, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-porter-app-event")

+ 5 - 7
api/server/handlers/porter_app/list_events.go

@@ -80,9 +80,7 @@ func (p *PorterAppEventListHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		if appEvent.Status == "PROGRESSING" {
 			pae, err := p.updateExistingAppEvent(ctx, *cluster, stackName, *appEvent, user, project)
 			if err != nil {
-				e := telemetry.Error(ctx, span, nil, "unable to update existing porter app event")
-				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
-				return
+				telemetry.Error(ctx, span, nil, "unable to update existing porter app event")
 			}
 			porterAppEvents[idx] = &pae
 		}
@@ -130,14 +128,13 @@ func (p *PorterAppEventListHandler) updateExistingAppEvent(
 		telemetry.AttributeKV{Key: "porter-app-id", Value: event.PorterAppID},
 		telemetry.AttributeKV{Key: "porter-app-event-id", Value: event.ID.String()},
 		telemetry.AttributeKV{Key: "porter-app-event-status", Value: event.Status},
-		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
-		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
 	)
 
+	// TODO: get rid of this block and related methods if still here after 08-04-2023
 	if appEvent.Type == string(types.PorterAppEventType_Build) && appEvent.TypeExternalSource == "GITHUB" {
 		err = p.updateBuildEvent_Github(ctx, &event, user, project, stackName)
 		if err != nil {
-			return models.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error updating porter app event for github build")
+			return appEvent, telemetry.Error(ctx, span, err, "error updating porter app event for github build")
 		}
 	}
 
@@ -223,9 +220,10 @@ func (p *PorterAppEventListHandler) updateBuildEvent_Github(
 	if *actionRun.Status == "completed" {
 		if *actionRun.Conclusion == "success" {
 			event.Status = "SUCCESS"
+			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", "SUCCESS")
 		} else {
 			event.Status = "FAILED"
-			_ = TrackStackBuildFailure(p.Config(), user, project, stackName)
+			_ = TrackStackBuildStatus(p.Config(), user, project, stackName, "", "FAILED")
 		}
 		event.Metadata["end_time"] = actionRun.GetUpdatedAt().Time
 	}

+ 1 - 0
api/server/handlers/project/create.go

@@ -44,6 +44,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		CapiProvisionerEnabled: true,
 		SimplifiedViewEnabled:  true,
 		HelmValuesEnabled:      false,
+		EnvGroupEnabled:        false,
 	}
 
 	var err error

+ 1 - 0
api/server/handlers/project/create_test.go

@@ -45,6 +45,7 @@ func TestCreateProjectSuccessful(t *testing.T) {
 		CapiProvisionerEnabled: true,
 		SimplifiedViewEnabled:  true,
 		HelmValuesEnabled:      false,
+		EnvGroupEnabled:        false,
 	}
 
 	gotProject := &types.CreateProjectResponse{}

+ 2 - 0
api/types/project.go

@@ -13,6 +13,7 @@ type Project struct {
 	SimplifiedViewEnabled  bool    `json:"simplified_view_enabled"`
 	AzureEnabled           bool    `json:"azure_enabled"`
 	HelmValuesEnabled      bool    `json:"helm_values_enabled"`
+	EnvGroupEnabled        bool    `json:"env_group_enabled"`
 }
 
 type FeatureFlags struct {
@@ -24,6 +25,7 @@ type FeatureFlags struct {
 	SimplifiedViewEnabled      string `json:"simplified_view_enabled,omitempty"`
 	AzureEnabled               bool   `json:"azure_enabled,omitempty"`
 	HelmValuesEnabled          bool   `json:"helm_values_enabled,omitempty"`
+	EnvGroupEnabled            bool   `json:"env_group_enabled"`
 }
 
 type CreateProjectRequest struct {

+ 43 - 41
cli/cmd/stack/apply.go

@@ -47,7 +47,7 @@ func CreateApplicationDeploy(client *api.Client, worker *switchboardWorker.Worke
 		return nil, fmt.Errorf("malformed application definition: %w", err)
 	}
 
-	deployStackHook := &DeployAppHook{
+	deployAppHook := &DeployAppHook{
 		Client:               client,
 		ApplicationName:      applicationName,
 		ProjectID:            cliConf.Project,
@@ -59,60 +59,63 @@ func CreateApplicationDeploy(client *api.Client, worker *switchboardWorker.Worke
 		EnvironmentMeta:      envMeta,
 	}
 
-	worker.RegisterHook("deploy-stack", deployStackHook)
-	if os.Getenv("GITHUB_RUN_ID") != "" {
-		err := createAppEvent(client, applicationName, cliConf)
-		if err != nil {
-			return nil, err
-		}
-	}
-
+	worker.RegisterHook("deploy-app", deployAppHook)
 	return resources, nil
 }
 
 // Create app event to signfy start of build
-func createAppEvent(client *api.Client, applicationName string, cliConf *config.CLIConfig) error {
-	req := &types.CreateOrUpdatePorterAppEventRequest{
-		Status:             "PROGRESSING",
-		Type:               types.PorterAppEventType_Build,
-		TypeExternalSource: "GITHUB",
-		Metadata: map[string]any{
-			"action_run_id": os.Getenv("GITHUB_RUN_ID"),
-			"org":           os.Getenv("GITHUB_REPOSITORY_OWNER"),
-		},
-	}
+func createAppEvent(client *api.Client, applicationName string, projectId, clusterId uint) (string, error) {
+	var req *types.CreateOrUpdatePorterAppEventRequest
+	if os.Getenv("GITHUB_RUN_ID") != "" {
+		req = &types.CreateOrUpdatePorterAppEventRequest{
+			Status:             "PROGRESSING",
+			Type:               types.PorterAppEventType_Build,
+			TypeExternalSource: "GITHUB",
+			Metadata: map[string]any{
+				"action_run_id": os.Getenv("GITHUB_RUN_ID"),
+				"org":           os.Getenv("GITHUB_REPOSITORY_OWNER"),
+			},
+		}
 
-	repoNameSplit := strings.Split(os.Getenv("GITHUB_REPOSITORY"), "/")
-	if len(repoNameSplit) != 2 {
-		return fmt.Errorf("unable to parse GITHUB_REPOSITORY")
-	}
-	req.Metadata["repo"] = repoNameSplit[1]
+		repoNameSplit := strings.Split(os.Getenv("GITHUB_REPOSITORY"), "/")
+		if len(repoNameSplit) != 2 {
+			return "", fmt.Errorf("unable to parse GITHUB_REPOSITORY")
+		}
+		req.Metadata["repo"] = repoNameSplit[1]
 
-	actionRunID := os.Getenv("GITHUB_RUN_ID")
-	if actionRunID != "" {
-		arid, err := strconv.Atoi(actionRunID)
-		if err != nil {
-			return fmt.Errorf("unable to parse GITHUB_RUN_ID as int: %w", err)
+		actionRunID := os.Getenv("GITHUB_RUN_ID")
+		if actionRunID != "" {
+			arid, err := strconv.Atoi(actionRunID)
+			if err != nil {
+				return "", fmt.Errorf("unable to parse GITHUB_RUN_ID as int: %w", err)
+			}
+			req.Metadata["action_run_id"] = arid
 		}
-		req.Metadata["action_run_id"] = arid
-	}
 
-	repoOwnerAccountID := os.Getenv("GITHUB_REPOSITORY_OWNER_ID")
-	if repoOwnerAccountID != "" {
-		arid, err := strconv.Atoi(repoOwnerAccountID)
-		if err != nil {
-			return fmt.Errorf("unable to parse GITHUB_REPOSITORY_OWNER_ID as int: %w", err)
+		repoOwnerAccountID := os.Getenv("GITHUB_REPOSITORY_OWNER_ID")
+		if repoOwnerAccountID != "" {
+			arid, err := strconv.Atoi(repoOwnerAccountID)
+			if err != nil {
+				return "", fmt.Errorf("unable to parse GITHUB_REPOSITORY_OWNER_ID as int: %w", err)
+			}
+			req.Metadata["github_account_id"] = arid
+		}
+	} else {
+		req = &types.CreateOrUpdatePorterAppEventRequest{
+			Status:             "PROGRESSING",
+			Type:               types.PorterAppEventType_Build,
+			TypeExternalSource: "GITHUB",
+			Metadata:           map[string]any{},
 		}
-		req.Metadata["github_account_id"] = arid
 	}
 
 	ctx := context.Background()
-	_, err := client.CreateOrUpdatePorterAppEvent(ctx, cliConf.Project, cliConf.Cluster, applicationName, req)
+	event, err := client.CreateOrUpdatePorterAppEvent(ctx, projectId, clusterId, applicationName, req)
 	if err != nil {
-		return fmt.Errorf("unable to create porter app build event: %w", err)
+		return "", fmt.Errorf("unable to create porter app build event: %w", err)
 	}
 
-	return nil
+	return event.ID, nil
 }
 
 func createV1BuildResources(client *api.Client, app *Application, stackConf *StackConf, envMeta EnvironmentMeta) ([]*switchboardTypes.Resource, string, error) {
@@ -162,7 +165,6 @@ func createV1BuildResources(client *api.Client, app *Application, stackConf *Sta
 			stackConf.clusterID,
 			stackConf.parsed.Env,
 		)
-
 		if err != nil {
 			return nil, "", err
 		}

+ 41 - 8
cli/cmd/stack/hooks.go

@@ -21,6 +21,7 @@ type DeployAppHook struct {
 	Builder              string
 	Namespace            string
 	EnvironmentMeta      EnvironmentMeta
+	BuildEventID         string
 }
 
 func (t *DeployAppHook) PreApply() error {
@@ -29,6 +30,13 @@ func (t *DeployAppHook) PreApply() error {
 		errMsg := composePreviewMessage("porter CLI is not configured correctly", Error)
 		return fmt.Errorf("%s: %w", errMsg, err)
 	}
+
+	buildEventId, err := createAppEvent(t.Client, t.ApplicationName, t.ProjectID, t.ClusterID)
+	if err != nil {
+		return err
+	}
+	t.BuildEventID = buildEventId
+
 	return nil
 }
 
@@ -41,9 +49,15 @@ func (t *DeployAppHook) DataQueries() map[string]interface{} {
 
 // deploy the app
 func (t *DeployAppHook) PostApply(driverOutput map[string]interface{}) error {
-	client := config.GetAPIClient()
+	eventRequest := types.CreateOrUpdatePorterAppEventRequest{
+		Status:   "SUCCESS",
+		Type:     types.PorterAppEventType_Build,
+		Metadata: map[string]any{},
+		ID:       t.BuildEventID,
+	}
+	_, _ = t.Client.CreateOrUpdatePorterAppEvent(context.Background(), t.ProjectID, t.ClusterID, t.ApplicationName, &eventRequest)
 
-	_, err := client.GetRelease(
+	_, err := t.Client.GetRelease(
 		context.Background(),
 		t.ProjectID,
 		t.ClusterID,
@@ -59,10 +73,10 @@ func (t *DeployAppHook) PostApply(driverOutput map[string]interface{}) error {
 		color.New(color.FgGreen).Printf("Found release for app %s: attempting update\n", t.ApplicationName)
 	}
 
-	return t.applyApp(client, shouldCreate, driverOutput)
+	return t.applyApp(shouldCreate, driverOutput)
 }
 
-func (t *DeployAppHook) applyApp(client *api.Client, shouldCreate bool, driverOutput map[string]interface{}) error {
+func (t *DeployAppHook) applyApp(shouldCreate bool, driverOutput map[string]interface{}) error {
 	var imageInfo types.ImageInfo
 	image, ok := driverOutput["image"].(string)
 	// if it contains a $, then it means the query didn't resolve to anything
@@ -89,7 +103,7 @@ func (t *DeployAppHook) applyApp(client *api.Client, shouldCreate bool, driverOu
 		EnvironmentConfigID: t.EnvironmentMeta.EnvironmentConfigID,
 	}
 
-	_, err := client.CreatePorterApp(
+	_, err := t.Client.CreatePorterApp(
 		context.Background(),
 		t.ProjectID,
 		t.ClusterID,
@@ -107,7 +121,7 @@ func (t *DeployAppHook) applyApp(client *api.Client, shouldCreate bool, driverOu
 	if t.EnvironmentMeta.GitHubMetadata.BranchFrom != "" {
 		color.New(color.FgGreen).Printf("Creating preview environment for app %s based on branch '%s'\n", t.ApplicationName, t.EnvironmentMeta.GitHubMetadata.BranchFrom)
 		// create preview env record
-		_, err = client.CreatePreviewEnvironment(
+		_, err = t.Client.CreatePreviewEnvironment(
 			context.Background(),
 			t.ProjectID,
 			t.ClusterID,
@@ -127,5 +141,24 @@ func (t *DeployAppHook) applyApp(client *api.Client, shouldCreate bool, driverOu
 	return nil
 }
 
-func (t *DeployAppHook) OnConsolidatedErrors(map[string]error) {}
-func (t *DeployAppHook) OnError(error)                         {}
+func (t *DeployAppHook) OnConsolidatedErrors(errors map[string]error) {
+	errorStringMap := make(map[string]string)
+	for k, v := range errors {
+		errorStringMap[k] = fmt.Sprintf("%+v", v)
+	}
+	eventRequest := types.CreateOrUpdatePorterAppEventRequest{
+		Status: "FAILED",
+		Type:   types.PorterAppEventType_Build,
+		Metadata: map[string]any{
+			"errors": errorStringMap,
+		},
+		ID: t.BuildEventID,
+	}
+	_, _ = t.Client.CreateOrUpdatePorterAppEvent(context.Background(), t.ProjectID, t.ClusterID, t.ApplicationName, &eventRequest)
+}
+
+func (t *DeployAppHook) OnError(err error) {
+	t.OnConsolidatedErrors(map[string]error{
+		"pre-apply": err,
+	})
+}

+ 3 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -20,6 +20,7 @@ import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Container from "components/porter/Container";
 import EventFocusView from "./events/focus-views/EventFocusView";
+import { PorterAppEvent } from "shared/types";
 
 type Props = {
   chart: any;
@@ -31,7 +32,7 @@ type Props = {
 const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
-  const [events, setEvents] = useState<any[]>([]);
+  const [events, setEvents] = useState<PorterAppEvent[]>([]);
   const [loading, setLoading] = useState<boolean>(true);
   const [error, setError] = useState<any>(null);
   const [page, setPage] = useState<number>(1);
@@ -59,7 +60,7 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
       );
 
       setNumPages(res.data.num_pages);
-      setEvents(res.data.events);
+      setEvents(res.data.events?.map((event: any) => PorterAppEvent.toPorterAppEvent(event)) ?? []);
     } catch (err) {
       setError(err);
     } finally {

+ 1 - 81
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 
 import build from "assets/build.png";
@@ -11,11 +11,7 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Icon from "components/porter/Icon";
-import api from "shared/api";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
-import JSZip from "jszip";
-import Anser, { AnserJsonEntry } from "anser";
-import GHALogsModal from "../../../status/GHALogsModal";
 import { PorterAppEvent } from "shared/types";
 import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
@@ -27,9 +23,6 @@ type Props = {
 };
 
 const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
-  const [logModalVisible, setLogModalVisible] = useState(false);
-  const [logs, setLogs] = useState<Log[]>([]);
-
   const renderStatusText = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":
@@ -41,79 +34,6 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
     }
   };
 
-  const getBuildLogs = async () => {
-    try {
-      setLogs([]);
-      setLogModalVisible(true);
-
-      const res = await api.getGHWorkflowLogById(
-        "",
-        {},
-        {
-          project_id: appData.app.project_id,
-          cluster_id: appData.app.cluster_id,
-          git_installation_id: appData.app.git_repo_id,
-          owner: appData.app.repo_name?.split("/")[0],
-          name: appData.app.repo_name?.split("/")[1],
-          filename: "porter_stack_" + appData.chart.name + ".yml",
-          run_id: event.metadata.action_run_id,
-        }
-      );
-      let logs: Log[] = [];
-      if (res.data != null) {
-        // Fetch the logs
-        const logsResponse = await fetch(res.data);
-
-        // Ensure that the response body is only read once
-        const logsBlob = await logsResponse.blob();
-
-        if (logsResponse.headers.get("Content-Type") === "application/zip") {
-          const zip = await JSZip.loadAsync(logsBlob);
-          const promises: any[] = [];
-
-          zip.forEach(function (relativePath, zipEntry) {
-            promises.push(
-              (async function () {
-                const fileData = await zip
-                  .file(relativePath)
-                  ?.async("string");
-
-                if (
-                  fileData &&
-                  fileData.includes("Run porter-dev/porter-cli-action@v0.1.0")
-                ) {
-                  const lines = fileData.split("\n");
-                  const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
-
-                  lines.forEach((line, index) => {
-                    const lineWithoutTimestamp = line.replace(timestampPattern, "").trimStart();
-                    const anserLine: AnserJsonEntry[] = Anser.ansiToJson(lineWithoutTimestamp);
-                    if (lineWithoutTimestamp.toLowerCase().includes("error")) {
-                      anserLine[0].fg = "238,75,43";
-                    }
-
-                    const log: Log = {
-                      line: anserLine,
-                      lineNumber: index + 1,
-                      timestamp: line.match(timestampPattern)?.[0],
-                    };
-
-                    logs.push(log);
-                  });
-                }
-              })()
-            );
-          });
-
-          await Promise.all(promises);
-          setLogs(logs);
-        }
-      }
-    } catch (error) {
-      console.log(error);
-    }
-  };
-
   const renderInfoCta = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":

+ 1 - 1
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx

@@ -39,7 +39,7 @@ const EventFocusView: React.FC<Props> = ({
                         event_id: eventId,
                     }
                 )
-                const newEvent = eventResp.data.event as PorterAppEvent;
+                const newEvent = PorterAppEvent.toPorterAppEvent(eventResp.data.event);
                 setEvent(newEvent);
                 if (newEvent.metadata.end_time != null) {
                     clearInterval(intervalId);

+ 36 - 32
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx

@@ -123,39 +123,43 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
         fileUpload={true}
         syncedEnvGroups={syncedEnvGroups}
       />
-      <LoadButton
-        onClick={() => setShowEnvModal(true)}
-      >
-        <img src={sliders} /> Load from Env Group
-      </LoadButton>
-      {showEnvModal && <EnvGroupModal
-        setValues={(x: any) => {
-          if (status !== "") {
-            clearStatus();
-          }
-          setEnvVars(x);
-        }}
-        values={envVars}
-        closeModal={() => setShowEnvModal(false)}
-        syncedEnvGroups={syncedEnvGroups}
-        setSyncedEnvGroups={setSyncedEnvGroups}
-        namespace={appData.chart.namespace}
-      />}
-      {!!syncedEnvGroups?.length && (
+      {currentProject.env_group_enabled && (
         <>
-          <Spacer y={0.5} />
-          <Text size={16}>Synced environment groups</Text >
-          {syncedEnvGroups?.map((envGroup: any) => {
-            return (
-              <ExpandableEnvGroup
-                key={envGroup?.name}
-                envGroup={envGroup}
-                onDelete={() => {
-                  deleteEnvGroup(envGroup);
-                }}
-              />
-            );
-          })}
+          <LoadButton
+            onClick={() => setShowEnvModal(true)}
+          >
+            <img src={sliders} /> Load from Env Group
+          </LoadButton>
+          {showEnvModal && <EnvGroupModal
+            setValues={(x: any) => {
+              if (status !== "") {
+                clearStatus();
+              }
+              setEnvVars(x);
+            }}
+            values={envVars}
+            closeModal={() => setShowEnvModal(false)}
+            syncedEnvGroups={syncedEnvGroups}
+            setSyncedEnvGroups={setSyncedEnvGroups}
+            namespace={appData.chart.namespace}
+          />}
+          {!!syncedEnvGroups?.length && (
+            <>
+              <Spacer y={0.5} />
+              <Text size={16}>Synced environment groups</Text >
+              {syncedEnvGroups?.map((envGroup: any) => {
+                return (
+                  <ExpandableEnvGroup
+                    key={envGroup?.name}
+                    envGroup={envGroup}
+                    onDelete={() => {
+                      deleteEnvGroup(envGroup);
+                    }}
+                  />
+                );
+              })}
+            </>
+          )}
         </>
       )}
 

+ 34 - 30
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -581,37 +581,41 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                   fileUpload={true}
                   syncedEnvGroups={syncedEnvGroups}
                 />
-                <LoadButton
-                  onClick={() => setShowEnvModal(true)}
-                >
-                  <img src={sliders} /> Load from Env Group
-                </LoadButton>
-                {showEnvModal && <EnvGroupModal
-                  setValues={(x: any) => {
-                    setFormState({ ...formState, envVariables: x });
-                  }}
-                  values={formState.envVariables}
-                  closeModal={() => setShowEnvModal(false)}
-                  syncedEnvGroups={syncedEnvGroups}
-                  setSyncedEnvGroups={setSyncedEnvGroups}
-                  namespace={"porter-stack-" + porterApp.name}
-                  newApp={true}
-                />}
-                {!!syncedEnvGroups?.length && (
+                {currentProject.env_group_enabled && (
                   <>
-                    <Spacer y={0.5} />
-                    <Text size={16}>Synced environment groups</Text >
-                    {syncedEnvGroups?.map((envGroup: any) => {
-                      return (
-                        <ExpandableEnvGroup
-                          key={envGroup?.name}
-                          envGroup={envGroup}
-                          onDelete={() => {
-                            deleteEnvGroup(envGroup);
-                          }}
-                        />
-                      );
-                    })}
+                    <LoadButton
+                      onClick={() => setShowEnvModal(true)}
+                    >
+                      <img src={sliders} /> Load from Env Group
+                    </LoadButton>
+                    {showEnvModal && <EnvGroupModal
+                      setValues={(x: any) => {
+                        setFormState({ ...formState, envVariables: x });
+                      }}
+                      values={formState.envVariables}
+                      closeModal={() => setShowEnvModal(false)}
+                      syncedEnvGroups={syncedEnvGroups}
+                      setSyncedEnvGroups={setSyncedEnvGroups}
+                      namespace={"porter-stack-" + porterApp.name}
+                      newApp={true}
+                    />}
+                    {!!syncedEnvGroups?.length && (
+                      <>
+                        <Spacer y={0.5} />
+                        <Text size={16}>Synced environment groups</Text >
+                        {syncedEnvGroups?.map((envGroup: any) => {
+                          return (
+                            <ExpandableEnvGroup
+                              key={envGroup?.name}
+                              envGroup={envGroup}
+                              onDelete={() => {
+                                deleteEnvGroup(envGroup);
+                              }}
+                            />
+                          );
+                        })}
+                      </>
+                    )}
                   </>
                 )}
               </>,

+ 40 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -6,7 +6,7 @@ import { PorterJson } from "./schema";
 export type Service = WorkerService | WebService | JobService | ReleaseService;
 export type ServiceType = 'web' | 'worker' | 'job' | 'release';
 
-type ServiceString = {
+export type ServiceString = {
     readOnly: boolean;
     value: string;
 }
@@ -14,11 +14,31 @@ type ServiceBoolean = {
     readOnly: boolean;
     value: boolean;
 }
+export type ServiceArray<T extends ServiceString | ServiceBoolean> = {
+    key: string;
+    value: T;
+}[];
+const ServiceArray = {
+    serialize: <T extends ServiceString | ServiceBoolean>(serviceArray: ServiceArray<T>) => {
+        const map: Record<string, string> = {};
+        serviceArray.map(({ key, value }: {
+            key: string;
+            value: T;
+        }) => {
+            if (key != '') {
+                map[key] = value.value.toString();
+            }
+        });
+        return map;
+    }
+}
+
 type Ingress = {
     enabled: ServiceBoolean;
     customDomain: ServiceString;
     hosts: ServiceString;
     porterHosts: ServiceString;
+    annotations: ServiceArray<ServiceString>;
 }
 type Autoscaling = {
     enabled: ServiceBoolean,
@@ -71,6 +91,22 @@ const ServiceField = {
             value: overrideValue ?? defaultValue,
         }
     },
+    array: (defaultMap: Record<string, string>, overrideMap?: Record<string, string>): ServiceArray<ServiceString> => {
+        const serviceMap: Record<string, ServiceString> = {};
+        for (const key in defaultMap) {
+            serviceMap[key] = ServiceField.string(defaultMap[key]);
+        }
+        for (const key in overrideMap) {
+            serviceMap[key] = ServiceField.string('', overrideMap[key]);
+        }
+        if (Object.keys(serviceMap).length == 0) {
+            return [];
+        }
+        return Object.keys(serviceMap).map((key) => ({
+            key,
+            value: serviceMap[key],
+        }));
+    }
 }
 
 type SharedServiceParams = {
@@ -109,6 +145,7 @@ const WebService = {
             customDomain: ServiceField.string('', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
             hosts: ServiceField.string('', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
             porterHosts: ServiceField.string('', porterJson?.apps?.[name]?.config?.ingress?.porter_hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.porter_hosts[0] : undefined),
+            annotations: ServiceField.array({}, porterJson?.apps?.[name]?.config?.ingress?.annotations)
         },
         port: ServiceField.string('3000', porterJson?.apps?.[name]?.config?.container?.port),
         canDelete: porterJson?.apps?.[name] == null,
@@ -164,6 +201,7 @@ const WebService = {
                 custom_domain: service.ingress.customDomain.value ? true : false,
                 hosts: service.ingress.customDomain.value ? [service.ingress.customDomain.value] : [],
                 porter_hosts: service.ingress.porterHosts.value ? [service.ingress.porterHosts.value] : [],
+                annotations: ServiceArray.serialize(service.ingress.annotations),
             },
             service: {
                 port: service.port.value,
@@ -216,6 +254,7 @@ const WebService = {
                 customDomain: ServiceField.string(values.ingress?.hosts?.length ? values.ingress.hosts[0] : '', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
                 hosts: ServiceField.string(values.ingress?.hosts?.length ? values.ingress.hosts[0] : '', porterJson?.apps?.[name]?.config?.ingress?.hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.hosts[0] : undefined),
                 porterHosts: ServiceField.string(values.ingress?.porter_hosts?.length ? values.ingress.porter_hosts[0] : '', porterJson?.apps?.[name]?.config?.ingress?.porter_hosts?.length ? porterJson?.apps?.[name]?.config?.ingress?.porter_hosts[0] : undefined),
+                annotations: ServiceField.array(values.ingress?.annotations ?? {}, porterJson?.apps?.[name]?.config?.ingress?.annotations),
             },
             port: ServiceField.string(values.container?.port ?? '', porterJson?.apps?.[name]?.config?.container?.port),
             canDelete: porterJson?.apps?.[name] == null,

+ 116 - 0
dashboard/src/main/home/app-dashboard/new-app-flow/tabs/IngressCustomAnnotations.tsx

@@ -0,0 +1,116 @@
+import React from 'react';
+import { ServiceArray, ServiceString } from '../serviceTypes';
+import Button from 'components/porter/Button';
+import styled from 'styled-components';
+import Input from 'components/porter/Input';
+import Spacer from 'components/porter/Spacer';
+
+interface Props {
+    annotations: ServiceArray<ServiceString>;
+    onChange: (annotations: ServiceArray<ServiceString>) => void;
+}
+
+const IngressCustomAnnotations: React.FC<Props> = ({ annotations, onChange }) => {
+    const renderInputs = () => {
+        return annotations.map(({ key: annotationKey, value: annotationValue }, i) => {
+            return (
+                <>
+                    <AnnotationContainer key={i}>
+                        <Input
+                            placeholder="kubernetes.io/ingress.class"
+                            value={annotationKey}
+                            setValue={(e) => {
+                                const newAnnotations = [...annotations];
+                                newAnnotations[i].key = e;
+                                onChange(newAnnotations);
+                            }}
+                            disabled={annotationValue.readOnly}
+                            width="275px"
+                            disabledTooltip={
+                                "You may only edit this field in your porter.yaml."
+                            }
+                        />
+                        <Input
+                            placeholder="nginx"
+                            value={annotationValue.value}
+                            setValue={(e) => {
+                                const newAnnotations = [...annotations];
+                                newAnnotations[i].value = { readOnly: false, value: e };
+                                onChange(newAnnotations);
+                            }}
+                            disabled={annotationValue.readOnly}
+                            width="275px"
+                            disabledTooltip={
+                                "You may only edit this field in your porter.yaml."
+                            }
+                        />
+                        <DeleteButton
+                            onClick={() => {
+                                //remove annotation at the index
+                                const newAnnotations = [...annotations];
+                                newAnnotations.splice(i, 1);
+                                onChange(newAnnotations);
+                            }}
+                        >
+                            <i className="material-icons">cancel</i>
+                        </DeleteButton>
+                    </AnnotationContainer>
+                    <Spacer y={0.25} />
+                </>
+            );
+        });
+    };
+
+    return (
+        <IngressCustomAnnotationsContainer>
+            {annotations.length !== 0 &&
+                <>
+                    {renderInputs()}
+                    <Spacer y={0.5} />
+                </>
+            }
+            <Button
+                onClick={() => {
+                    const newAnnotations = [...annotations];
+                    newAnnotations.push({ key: "", value: { readOnly: false, value: "" } });
+                    onChange(newAnnotations);
+                }}
+            >
+                Add Annotation
+            </Button>
+        </IngressCustomAnnotationsContainer >
+    )
+};
+
+export default IngressCustomAnnotations;
+
+const IngressCustomAnnotationsContainer = styled.div`
+`;
+
+const AnnotationContainer = styled.div`
+    display: flex;
+    align-items: center;
+    gap: 5px;
+`
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;

+ 26 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/tabs/WebTabs.tsx

@@ -8,6 +8,7 @@ import { Service, WebService } from "../serviceTypes";
 import AnimateHeight, { Height } from "react-animate-height";
 import { Context } from "shared/Context";
 import { DATABASE_HEIGHT_DISABLED, DATABASE_HEIGHT_ENABLED, RESOURCE_HEIGHT_WITHOUT_AUTOSCALING, RESOURCE_HEIGHT_WITH_AUTOSCALING } from "./utils";
+import IngressCustomAnnotations from "./IngressCustomAnnotations";
 
 interface Props {
   service: WebService;
@@ -17,9 +18,10 @@ interface Props {
 
 
 const NETWORKING_HEIGHT_WITHOUT_INGRESS = 204;
-const NETWORKING_HEIGHT_WITH_INGRESS = 333;
+const NETWORKING_HEIGHT_WITH_INGRESS = 425;
 const ADVANCED_BASE_HEIGHT = 215;
 const PROBE_INPUTS_HEIGHT = 230;
+const CUSTOM_ANNOTATION_HEIGHT = 53;
 
 const WebTabs: React.FC<Props> = ({
   service,
@@ -66,7 +68,7 @@ const WebTabs: React.FC<Props> = ({
   };
 
   const renderNetworking = () => {
-    setHeight(service.ingress.enabled.value ? NETWORKING_HEIGHT_WITH_INGRESS : NETWORKING_HEIGHT_WITHOUT_INGRESS)
+    setHeight(service.ingress.enabled.value ? calculateNetworkingHeight() : NETWORKING_HEIGHT_WITHOUT_INGRESS)
     return (
       <>
         <Spacer y={1} />
@@ -134,6 +136,24 @@ const WebTabs: React.FC<Props> = ({
           />
           <Spacer y={1} />
           {getApplicationURLText()}
+          <Spacer y={1} />
+          <Text color="helper">
+            Ingress Custom Annotations
+            <a
+              href="https://docs.porter.run/standard/deploying-applications/runtime-configuration-options/web-applications#ingress-custom-annotations"
+              target="_blank"
+            >
+              &nbsp;(?)
+            </a>
+          </Text>
+          <Spacer y={0.5} />
+          <IngressCustomAnnotations
+            annotations={service.ingress.annotations}
+            onChange={(annotations) => {
+              editService({ ...service, ingress: { ...service.ingress, annotations: annotations } });
+              setHeight(calculateNetworkingHeight());
+            }}
+          />
         </AnimateHeight>
       </>
     );
@@ -422,6 +442,10 @@ const WebTabs: React.FC<Props> = ({
     return height;
   };
 
+  const calculateNetworkingHeight = () => {
+    return NETWORKING_HEIGHT_WITH_INGRESS + (service.ingress.annotations.length * CUSTOM_ANNOTATION_HEIGHT);
+  }
+
   const renderAdvanced = () => {
     setHeight(calculateHealthHeight());
     return (

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

@@ -189,7 +189,7 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={addOns} />
             Add-ons
           </NavButton>
-          <NavButton
+          {currentProject.env_group_enabled && <NavButton
             path="/env-groups"
 
             active={
@@ -199,7 +199,7 @@ class Sidebar extends Component<PropsType, StateType> {
           >
             <Img src={sliders} />
             Env groups
-          </NavButton>
+          </NavButton>}
           {this.props.isAuthorized("integrations", "", [
             "get",
             "create",

+ 15 - 0
dashboard/src/shared/types.tsx

@@ -271,6 +271,7 @@ export interface ProjectType {
   simplified_view_enabled: boolean;
   azure_enabled: boolean;
   helm_values_enabled: boolean;
+  env_group_enabled: boolean;
   roles: {
     id: number;
     kind: string;
@@ -681,5 +682,19 @@ export interface PorterAppEvent {
   porter_app_id: number;
   metadata: any;
 }
+export const PorterAppEvent = {
+  toPorterAppEvent: (data: any): PorterAppEvent => {
+    return {
+      created_at: data.created_at ?? "",
+      updated_at: data.updated_at ?? "",
+      id: data.id ?? "",
+      status: data.status ?? "",
+      type: data.type ?? "",
+      type_source: data.type_source ?? "",
+      porter_app_id: data.porter_app_id ?? "",
+      metadata: data.metadata ?? {},
+    };
+  }
+}
 
 

+ 2 - 1
docker/Dockerfile

@@ -2,7 +2,8 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.20-alpine as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc

+ 2 - 1
docker/cli.Dockerfile

@@ -2,7 +2,8 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.20 as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5 as base
 WORKDIR /porter
 
 RUN apt-get update && apt-get install -y gcc musl-dev git make

+ 2 - 1
docker/dev.Dockerfile

@@ -1,6 +1,7 @@
 # Development environment
 # -----------------------
-FROM golang:1.20-alpine
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git

+ 2 - 1
ee/docker/ee.Dockerfile

@@ -2,7 +2,8 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.20-alpine as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc

+ 2 - 1
ee/docker/provisioner.Dockerfile

@@ -2,7 +2,8 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.20-alpine as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc

+ 8 - 6
internal/analytics/track_events.go

@@ -48,10 +48,12 @@ const (
 	ClusterDestroyingSuccess SegmentEvent = "Cluster Destroying Success"
 
 	// porter apps
-	StackLaunchStart    SegmentEvent = "Stack Launch Started"
-	StackLaunchComplete SegmentEvent = "Stack Launch Complete"
-	StackLaunchSuccess  SegmentEvent = "Stack Launch Success"
-	StackLaunchFailure  SegmentEvent = "Stack Launch Failure"
-	StackDeletion       SegmentEvent = "Stack Deletion"
-	StackBuildFailure   SegmentEvent = "Stack Build Failure"
+	StackLaunchStart      SegmentEvent = "Stack Launch Started"
+	StackLaunchComplete   SegmentEvent = "Stack Launch Complete"
+	StackLaunchSuccess    SegmentEvent = "Stack Launch Success"
+	StackLaunchFailure    SegmentEvent = "Stack Launch Failure"
+	StackDeletion         SegmentEvent = "Stack Deletion"
+	StackBuildProgressing SegmentEvent = "Stack Build Progressing"
+	StackBuildFailure     SegmentEvent = "Stack Build Failure"
+	StackBuildSuccess     SegmentEvent = "Stack Build Success"
 )

+ 38 - 8
internal/analytics/tracks.go

@@ -855,21 +855,23 @@ func StackDeletionTrack(opts *StackDeletionOpts) segmentTrack {
 	)
 }
 
-// StackBuildFailureOpts are the options for creating a track when a stack fails to build
-type StackBuildFailureOpts struct {
+// StackBuildOpts are the options for creating a track when a stack builds
+type StackBuildOpts struct {
 	*ProjectScopedTrackOpts
 
-	StackName   string
-	Email       string
-	FirstName   string
-	LastName    string
-	CompanyName string
+	StackName    string
+	ErrorMessage string
+	Email        string
+	FirstName    string
+	LastName     string
+	CompanyName  string
 }
 
 // StackBuildFailureTrack returns a track for when a stack fails to build
-func StackBuildFailureTrack(opts *StackBuildFailureOpts) segmentTrack {
+func StackBuildFailureTrack(opts *StackBuildOpts) segmentTrack {
 	additionalProps := make(map[string]interface{})
 	additionalProps["stack_name"] = opts.StackName
+	additionalProps["error_message"] = opts.ErrorMessage
 	additionalProps["email"] = opts.Email
 	additionalProps["name"] = opts.FirstName + " " + opts.LastName
 	additionalProps["company"] = opts.CompanyName
@@ -879,3 +881,31 @@ func StackBuildFailureTrack(opts *StackBuildFailureOpts) segmentTrack {
 		getDefaultSegmentTrack(additionalProps, StackBuildFailure),
 	)
 }
+
+// StackBuildSuccessTrack returns a track for when a stack succeeds to build
+func StackBuildSuccessTrack(opts *StackBuildOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["stack_name"] = opts.StackName
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, StackBuildSuccess),
+	)
+}
+
+// StackBuildProgressingTrack returns a track for when a stack starts to build
+func StackBuildProgressingTrack(opts *StackBuildOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["stack_name"] = opts.StackName
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+
+	return getSegmentProjectTrack(
+		opts.ProjectScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, StackBuildProgressing),
+	)
+}

+ 2 - 0
internal/models/project.go

@@ -70,6 +70,7 @@ type Project struct {
 	SimplifiedViewEnabled  bool
 	AzureEnabled           bool
 	HelmValuesEnabled      bool
+	EnvGroupEnabled        bool `gorm:"default:false"`
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -93,5 +94,6 @@ func (p *Project) ToProjectType() *types.Project {
 		SimplifiedViewEnabled:  p.SimplifiedViewEnabled,
 		AzureEnabled:           p.AzureEnabled,
 		HelmValuesEnabled:      p.HelmValuesEnabled,
+		EnvGroupEnabled:        p.EnvGroupEnabled,
 	}
 }

+ 2 - 1
services/cli_install_script_container/Dockerfile

@@ -1,4 +1,5 @@
-FROM golang:1.20-alpine
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine
 
 WORKDIR /app
 COPY . .

+ 2 - 1
services/deploy_init_container/Dockerfile

@@ -1,4 +1,5 @@
-FROM golang:1.20-alpine as builder
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as builder
 
 WORKDIR /init-backend
 COPY main.go .

+ 2 - 1
services/migrator/Dockerfile

@@ -2,7 +2,8 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.20-alpine as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git

+ 2 - 1
services/porter_cli_container/dev.Dockerfile

@@ -2,7 +2,8 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.20 as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5 as base
 WORKDIR /porter
 
 RUN apt-get update && apt-get install -y gcc musl-dev git

+ 2 - 1
services/preview_env_setup_job/Dockerfile

@@ -1,5 +1,6 @@
 # Build
-FROM golang:1.20-alpine as base
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as base
 
 WORKDIR /app
 COPY . .

+ 2 - 1
workers/Dockerfile

@@ -2,7 +2,8 @@
 
 # Buildtime environment
 # -------------------------------------------
-FROM golang:1.20-alpine as build
+# pinned because of https://github.com/moby/moby/issues/45935
+FROM golang:1.20.5-alpine as build
 WORKDIR /app
 
 RUN apk update && apk add gcc binutils-gold musl-dev