Selaa lähdekoodia

Merge branch 'nafees/api-v1' of github.com:porter-dev/porter into nico/por-629-crud-operations-on-stack-resources

jnfrati 3 vuotta sitten
vanhempi
sitoutus
a0a0994f58
57 muutettua tiedostoa jossa 2372 lisäystä ja 1341 poistoa
  1. 84 60
      .github/workflows/dev.yaml
  2. 28 0
      .github/workflows/prerelease.yaml
  3. 1 25
      api/server/handlers/billing/billing_ce.go
  4. 1 13
      api/server/handlers/billing/billing_ee.go
  5. 65 0
      api/server/handlers/billing/redirect_billing.go
  6. 5 2
      api/server/handlers/cluster/update.go
  7. 24 0
      api/server/handlers/namespace/clone_env_group.go
  8. 2 12
      api/server/handlers/project/create.go
  9. 2 1
      api/server/handlers/project/delete.go
  10. 0 8
      api/server/handlers/project/delete_role.go
  11. 1 1
      api/server/handlers/project/get_billing.go
  12. 0 8
      api/server/handlers/project/update_role.go
  13. 183 0
      api/server/handlers/stack/add_application.go
  14. 150 0
      api/server/handlers/stack/add_env_group.go
  15. 130 0
      api/server/handlers/stack/remove_application.go
  16. 130 0
      api/server/handlers/stack/remove_env_group.go
  17. 24 2
      api/server/handlers/webhook/github_incoming.go
  18. 0 25
      api/server/router/base.go
  19. 12 14
      api/server/router/project.go
  20. 283 1
      api/server/router/v1/stack.go
  21. 1 1
      api/server/shared/commonutils/git_utils.go
  22. 4 3
      api/server/shared/config/env/envconfs.go
  23. 6 7
      api/server/shared/config/loader/init_ee.go
  24. 5 0
      api/types/cluster.go
  25. 7 0
      api/types/project.go
  26. 19 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  27. 20 25
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  28. 19 13
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  29. 1 0
      dashboard/src/shared/api.tsx
  30. 1 0
      dashboard/src/shared/types.tsx
  31. 0 163
      ee/api/server/handlers/billing/add_project.go
  32. 0 66
      ee/api/server/handlers/billing/get_token.go
  33. 33 1
      ee/api/server/handlers/billing/webhook.go
  34. 1 19
      ee/api/server/handlers/invite/accept.go
  35. 293 0
      ee/billing/client.go
  36. 0 677
      ee/billing/ironplans.go
  37. 30 125
      ee/billing/types.go
  38. 1 0
      go.mod
  39. 1 0
      go.sum
  40. 10 62
      internal/billing/billing.go
  41. 10 0
      internal/helm/agent.go
  42. 9 1
      internal/kubernetes/config.go
  43. 6 0
      internal/models/cluster.go
  44. 10 2
      internal/models/infra.go
  45. 11 4
      internal/models/integrations/aws.go
  46. 9 0
      internal/repository/gorm/project.go
  47. 1 0
      internal/repository/project.go
  48. 4 0
      internal/repository/test/project.go
  49. 76 0
      internal/worker/dispatcher.go
  50. 22 0
      internal/worker/dispatcher_test.go
  51. 73 0
      internal/worker/worker.go
  52. 25 0
      internal/worker/worker_test.go
  53. 20 0
      provisioner/integrations/storage/s3/s3.go
  54. 30 0
      workers/Dockerfile
  55. 28 0
      workers/doc.go
  56. 302 0
      workers/jobs/helm_revisions_count_tracker.go
  57. 159 0
      workers/main.go

+ 84 - 60
.github/workflows/dev.yaml

@@ -22,7 +22,7 @@ jobs:
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -50,7 +50,7 @@ jobs:
       - name: Deploy to cluster
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
-            
+
           kubectl rollout restart deployment/porter
   deploy-provisioner:
     runs-on: ubuntu-latest
@@ -70,7 +70,7 @@ jobs:
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -84,67 +84,91 @@ jobs:
       - name: Deploy to cluster
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
-            
+
           kubectl rollout restart deployment/provisioner
   build-push-ecr-server:
     runs-on: ubuntu-latest
     steps:
-    - name: Checkout code
-      uses: actions/checkout@v2.3.4
-    - name: Set Github tag
-      id: vars
-      run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
-    - name: Configure AWS credentials
-      uses: aws-actions/configure-aws-credentials@v1
-      with:
-        aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
-        aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
-        aws-region: us-east-2
-    - name: Login to ECR
-      id: login-ecr
-      run: |
-        aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
-    - name: Write Dashboard Environment Variables
-      run: |
-        cat >./dashboard/.env <<EOL
-        NODE_ENV=development
-        API_SERVER=dashboard.dev.getporter.dev
-        DISCORD_KEY=${{secrets.DISCORD_KEY}}
-        DISCORD_CID=${{secrets.DISCORD_CID}}
-        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-        APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
-        ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
-        ENABLE_SENTRY=true
-        SENTRY_DSN=${{secrets.SENTRY_DSN}}
-        SENTRY_ENV=frontend-development
-        EOL
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/ee.Dockerfile
-    - name: Push to ECR
-      run: |
-        docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }}
+      - name: Checkout code
+        uses: actions/checkout@v2.3.4
+      - name: Set Github tag
+        id: vars
+        run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR
+        id: login-ecr
+        run: |
+          aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=development
+          API_SERVER=dashboard.dev.getporter.dev
+          DISCORD_KEY=${{secrets.DISCORD_KEY}}
+          DISCORD_CID=${{secrets.DISCORD_CID}}
+          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
+          ENABLE_SENTRY=true
+          SENTRY_DSN=${{secrets.SENTRY_DSN}}
+          SENTRY_ENV=frontend-development
+          EOL
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/ee.Dockerfile
+      - name: Push to ECR
+        run: |
+          docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }}
   build-push-ecr-provisioner:
     runs-on: ubuntu-latest
     steps:
-    - name: Checkout code
-      uses: actions/checkout@v2.3.4
-    - name: Set Github tag
-      id: vars
-      run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
-    - name: Configure AWS credentials
-      uses: aws-actions/configure-aws-credentials@v1
-      with:
-        aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
-        aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
-        aws-region: us-east-2
-    - name: Login to ECR
-      id: login-ecr
-      run: |
-        aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/provisioner.Dockerfile
-    - name: Push to ECR
-      run: |
-        docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }}
+      - name: Checkout code
+        uses: actions/checkout@v2.3.4
+      - name: Set Github tag
+        id: vars
+        run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR
+        id: login-ecr
+        run: |
+          aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/provisioner.Dockerfile
+      - name: Push to ECR
+        run: |
+          docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }}
+  build-push-worker-pool:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2.3.4
+      - name: Set Github tag
+        id: vars
+        run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR
+        id: login-ecr
+        run: |
+          aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }} -f ./workers/Dockerfile
+      - name: Push to ECR
+        run: |
+          docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }}

+ 28 - 0
.github/workflows/prerelease.yaml

@@ -78,6 +78,34 @@ jobs:
       - name: Push to ECR public
         run: |
           docker push public.ecr.aws/o1j4x7p4/provisioner-service:${{steps.tag_name.outputs.tag}}
+  build-push-worker-pool:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t public.ecr.aws/o1j4x7p4/worker-pool:${{steps.tag_name.outputs.tag}} -f ./workers/Dockerfile
+      - name: Push to ECR public
+        run: |
+          docker push public.ecr.aws/o1j4x7p4/worker-pool:${{steps.tag_name.outputs.tag}}
   build-linux:
     name: Build Linux binaries
     runs-on: ubuntu-latest

+ 1 - 25
api/server/handlers/billing/billing_ce.go

@@ -1,3 +1,4 @@
+//go:build !ee
 // +build !ee
 
 package billing
@@ -10,19 +11,6 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 )
 
-type BillingGetTokenHandler struct {
-	handlers.PorterHandlerReader
-	handlers.Unavailable
-}
-
-func NewBillingGetTokenHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) http.Handler {
-	return handlers.NewUnavailable(config, "billing_get_token")
-}
-
 type BillingWebhookHandler struct {
 	handlers.PorterHandlerReader
 	handlers.Unavailable
@@ -34,15 +22,3 @@ func NewBillingWebhookHandler(
 ) http.Handler {
 	return handlers.NewUnavailable(config, "billing_webhook")
 }
-
-type BillingAddProjectHandler struct {
-	handlers.PorterHandlerReader
-	handlers.Unavailable
-}
-
-func NewBillingAddProjectHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler {
-	return handlers.NewUnavailable(config, "billing_add_project")
-}

+ 1 - 13
api/server/handlers/billing/billing_ee.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package billing
@@ -11,24 +12,11 @@ import (
 	"github.com/porter-dev/porter/ee/api/server/handlers/billing"
 )
 
-var NewBillingGetTokenHandler func(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) http.Handler
-
 var NewBillingWebhookHandler func(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 ) http.Handler
 
-var NewBillingAddProjectHandler func(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler
-
 func init() {
-	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
 	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
-	NewBillingAddProjectHandler = billing.NewBillingAddProjectHandler
 }

+ 65 - 0
api/server/handlers/billing/redirect_billing.go

@@ -0,0 +1,65 @@
+package billing
+
+import (
+	"net/http"
+	"net/url"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type RedirectBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewRedirectBillingHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *RedirectBillingHandler {
+	return &RedirectBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *RedirectBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if len(proj.Roles) == 0 {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Only the creator of the project can manage billing"), 302)
+		return
+	}
+
+	// at the moment, the user must be the first admin user on the project - otherwise, redirect back to
+	// home page with error
+	var firstAdminRoleID uint = proj.Roles[0].ID
+	var currUserRoleID uint = 0
+
+	for _, role := range proj.Roles {
+		if role.UserID == user.ID && role.Kind == types.RoleAdmin {
+			currUserRoleID = role.ID
+		}
+
+		if role.Kind == types.RoleAdmin && role.ID <= firstAdminRoleID {
+			firstAdminRoleID = role.ID
+		}
+	}
+
+	if currUserRoleID == 0 || currUserRoleID != firstAdminRoleID {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Only the creator of the project can manage billing"), 302)
+		return
+	}
+
+	redirectURI, err := c.Config().BillingManager.GetRedirectURI(user, proj)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	http.Redirect(w, r, redirectURI, 302)
+}

+ 5 - 2
api/server/handlers/cluster/update.go

@@ -37,8 +37,9 @@ func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// if the cluster has an AWS integration, make sure that the old cluster name is set
-	if cluster.AWSIntegrationID != 0 {
+	// if the cluster has an AWS integration, and the request does not have a cluster name attached, make
+	// sure that the old cluster name is set
+	if cluster.AWSIntegrationID != 0 && request.AWSClusterID == "" {
 		awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(cluster.ProjectID, cluster.AWSIntegrationID)
 
 		if err != nil {
@@ -56,6 +57,8 @@ func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 				return
 			}
 		}
+	} else if request.AWSClusterID != "" {
+		cluster.AWSClusterID = request.AWSClusterID
 	}
 
 	cluster.Name = request.Name

+ 24 - 0
api/server/handlers/namespace/clone_env_group.go

@@ -1,6 +1,8 @@
 package namespace
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -10,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -50,12 +53,33 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	cm, _, err := agent.GetLatestVersionedConfigMap(request.Name, namespace)
 
 	if err != nil {
+		if errors.Is(err, kubernetes.IsNotFoundError) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error cloning env group: envgroup %s in namespace %s not found", request.Name, namespace), http.StatusNotFound,
+				"no config map found for envgroup",
+			))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	secret, _, err := agent.GetLatestVersionedSecret(request.Name, namespace)
 
+	if err != nil {
+		if errors.Is(err, kubernetes.IsNotFoundError) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error cloning env group: envgroup %s in namespace %s not found", request.Name, namespace), http.StatusNotFound,
+				"no k8s secret found for envgroup",
+			))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	if request.CloneName == "" {
 		request.CloneName = request.Name
 	}

+ 2 - 12
api/server/handlers/project/create.go

@@ -44,7 +44,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	var err error
-	proj, role, err := CreateProjectWithUser(p.Repo().Project(), proj, user)
+	proj, _, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -79,7 +79,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	p.WriteResult(w, r, proj.ToProjectType())
 
 	// add project to billing team
-	teamID, err := p.Config().BillingManager.CreateTeam(proj)
+	_, err = p.Config().BillingManager.CreateTeam(user, proj)
 
 	if err != nil {
 		// we do not write error response, since setting up billing error can be
@@ -87,16 +87,6 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
 	}
 
-	if teamID != "" {
-		err = p.Config().BillingManager.AddUserToTeam(teamID, user, role)
-
-		if err != nil {
-			// we do not write error response, since setting up billing error can be
-			// resolved later and may not be fatal
-			p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-		}
-	}
-
 	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
 		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
 	}))

+ 2 - 1
api/server/handlers/project/delete.go

@@ -25,6 +25,7 @@ func NewProjectDeleteHandler(
 }
 
 func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
 	proj, err := p.Repo().Project().DeleteProject(proj)
@@ -37,7 +38,7 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	p.WriteResult(w, r, proj.ToProjectType())
 
 	// delete the billing team
-	if err := p.Config().BillingManager.DeleteTeam(proj); err != nil {
+	if err := p.Config().BillingManager.DeleteTeam(user, proj); err != nil {
 		// we do not write error response, since setting up billing error can be
 		// resolved later and may not be fatal
 		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))

+ 0 - 8
api/server/handlers/project/delete_role.go

@@ -54,12 +54,4 @@ func (p *RoleDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	p.WriteResult(w, r, res)
-
-	err = p.Config().BillingManager.RemoveUserFromTeam(role)
-
-	if err != nil {
-		// we do not write error response, since setting up billing error can be
-		// resolved later and may not be fatal
-		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-	}
 }

+ 1 - 1
api/server/handlers/project/get_billing.go

@@ -30,7 +30,7 @@ func (p *ProjectGetBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		HasBilling: false,
 	}
 
-	if sc := p.Config().ServerConf; sc.IronPlansAPIKey != "" && sc.IronPlansServerURL != "" {
+	if sc := p.Config().ServerConf; sc.BillingPrivateKey != "" && sc.BillingPrivateServerURL != "" {
 		// determine if the project has usage attached; if so, set has_billing to true
 		usage, _ := p.Repo().ProjectUsage().ReadProjectUsage(proj.ID)
 

+ 0 - 8
api/server/handlers/project/update_role.go

@@ -57,12 +57,4 @@ func (p *RoleUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	p.WriteResult(w, r, res)
-
-	err = p.Config().BillingManager.UpdateUserInTeam(role)
-
-	if err != nil {
-		// we do not write error response, since setting up billing error can be
-		// resolved later and may not be fatal
-		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-	}
 }

+ 183 - 0
api/server/handlers/stack/add_application.go

@@ -0,0 +1,183 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+	helmrelease "helm.sh/helm/v3/pkg/release"
+)
+
+type StackAddApplicationHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddApplicationHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddApplicationHandler {
+	return &StackAddApplicationHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackAppResourceRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newResources, err := getResourceModels([]*types.CreateStackAppResourceRequest{req}, newSourceConfigs, p.Config().ServerConf.DefaultApplicationHelmRepoURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources = append(appResources, newResources...)
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmReleaseMap := make(map[string]*helmrelease.Release)
+
+	deployErrs := make([]string, 0)
+
+	for _, appResource := range newResources {
+		rel, err := applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    req,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		} else {
+			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+		}
+	}
+
+	// update stack revision status
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	saveErrs := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+			if err != nil {
+				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+			}
+		}
+	}
+
+	if len(saveErrs) > 0 {
+		revision.Reason = "SaveError"
+		revision.Message = strings.Join(saveErrs, " , ")
+
+		_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 150 - 0
api/server/handlers/stack/add_env_group.go

@@ -0,0 +1,150 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackAddEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddEnvGroupHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddEnvGroupHandler {
+	return &StackAddEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackEnvGroupRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newEnvGroups, err := getEnvGroupModels([]*types.CreateStackEnvGroupRequest{req}, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups = append(envGroups, newEnvGroups...)
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeployed),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroupDeployErrors := make([]string, 0)
+
+	cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+		Name:            req.Name,
+		Namespace:       namespace,
+		Variables:       req.Variables,
+		SecretVariables: req.SecretVariables,
+	})
+
+	if err != nil {
+		envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+	}
+
+	// add each of the linked applications to the env group
+	for _, appName := range req.LinkedApplications {
+		cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
+
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+		}
+	}
+
+	if len(envGroupDeployErrors) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 130 - 0
api/server/handlers/stack/remove_application.go

@@ -0,0 +1,130 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveApplicationHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveApplicationHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveApplicationHandler {
+	return &StackRemoveApplicationHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	appResourceName, reqErr := requestutils.GetURLParamString(r, "app_resource_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newResources []models.StackResource
+
+	for _, res := range appResources {
+		if res.Name != appResourceName {
+			newResources = append(newResources, res)
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      newResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = deleteAppResource(&deleteAppResourceOpts{
+		helmAgent: helmAgent,
+		name:      appResourceName,
+	})
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveAppError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 130 - 0
api/server/handlers/stack/remove_env_group.go

@@ -0,0 +1,130 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveEnvGroupHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveEnvGroupHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveEnvGroupHandler {
+	return &StackRemoveEnvGroupHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envGroupName, reqErr := requestutils.GetURLParamString(r, "env_group_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newEnvGroups []models.StackEnvGroup
+	var envGroupNS string
+
+	for _, envGroup := range envGroups {
+		if envGroup.Name != envGroupName {
+			newEnvGroups = append(newEnvGroups, envGroup)
+		} else {
+			envGroupNS = envGroup.Namespace
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      newEnvGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = envgroup.DeleteEnvGroup(k8sAgent, envGroupName, envGroupNS)
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveEnvGroupError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 24 - 2
api/server/handlers/webhook/github_incoming.go

@@ -158,7 +158,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
 				"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 		}
-	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
+	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" || event.GetAction() == "edited" {
 		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
 			env.ID, owner, repo, uint(event.GetPullRequest().GetNumber()),
 		)
@@ -191,7 +191,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, depl.ID,
 					event.GetPullRequest().GetNumber(), err)
 			}
-		} else {
+		} else if event.GetAction() == "closed" {
 			// check for already running workflows we should be cancelling
 			var wg sync.WaitGroup
 			statuses := []string{"in_progress", "queued", "requested", "waiting"}
@@ -245,6 +245,28 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					"deployment deleted but errors found while trying to cancel active workflow runs %w", webhookID, owner, repo, env.ID, depl.ID,
 					event.GetPullRequest().GetNumber(), chanErr)
 			}
+		} else if event.GetChanges() != nil {
+			shouldUpdate := false
+
+			if event.GetChanges().GetTitle() != nil && event.GetPullRequest().GetTitle() != depl.PRName {
+				depl.PRName = event.GetPullRequest().GetTitle()
+				shouldUpdate = true
+			}
+
+			if event.GetChanges().GetBase() != nil && event.GetChanges().GetBase().GetRef() != nil && event.GetPullRequest().GetBase().GetRef() != depl.PRBranchInto {
+				depl.PRBranchInto = event.GetPullRequest().GetBase().GetRef()
+				shouldUpdate = true
+			}
+
+			if shouldUpdate {
+				_, err := c.Repo().Environment().UpdateDeployment(depl)
+
+				if err != nil {
+					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d, prNumber: %d] "+
+						"error updating deployment to reflect changes in the pull request %w", webhookID, owner, repo, env.ID, depl.ID,
+						event.GetPullRequest().GetNumber(), err)
+				}
+			}
 		}
 	}
 

+ 0 - 25
api/server/router/base.go

@@ -4,7 +4,6 @@ import (
 	"fmt"
 
 	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
@@ -516,30 +515,6 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
-	// POST /api/internal/billing -> billing.NewBillingAddProjectHandler
-	addProjectBillingEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: "/internal/billing",
-			},
-			Scopes: []types.PermissionScope{},
-		},
-	)
-
-	addProjectBillingHandler := billing.NewBillingAddProjectHandler(
-		config,
-		factory.GetDecoderValidator(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: addProjectBillingEndpoint,
-		Handler:  addProjectBillingHandler,
-		Router:   r,
-	})
-
 	if config.ServerConf.GithubIncomingWebhookSecret != "" {
 		// POST /api/github/incoming_webhook/{webhook_id} -> webhook.NewGithubIncomingWebhook
 		githubIncomingWebhookEndpoint := factory.NewAPIEndpoint(

+ 12 - 14
api/server/router/project.go

@@ -227,14 +227,14 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing -> project.NewProjectGetBillingHandler
-	getBillingEndpoint := factory.NewAPIEndpoint(
+	// GET /api/project/{project_id}/billing/redirect -> billing.NewRedirectBillingHandler
+	redirectBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/billing",
+				RelativePath: relPath + "/billing/redirect",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -243,43 +243,41 @@ func getProjectRoutes(
 		},
 	)
 
-	getBillingHandler := project.NewProjectGetBillingHandler(
+	redirectBillingHandler := billing.NewRedirectBillingHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getBillingEndpoint,
-		Handler:  getBillingHandler,
+		Endpoint: redirectBillingEndpoint,
+		Handler:  redirectBillingHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing/token -> billing.NewBillingGetTokenEndpoint
-	getBillingTokenEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/billing -> project.NewProjectGetBillingHandler
+	getBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/billing/token",
+				RelativePath: relPath + "/billing",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
-				types.SettingsScope,
 			},
 		},
 	)
 
-	getBillingTokenHandler := billing.NewBillingGetTokenHandler(
+	getBillingHandler := project.NewProjectGetBillingHandler(
 		config,
-		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getBillingTokenEndpoint,
-		Handler:  getBillingTokenHandler,
+		Endpoint: getBillingEndpoint,
+		Handler:  getBillingHandler,
 		Router:   r,
 	})
 

+ 283 - 1
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -65,6 +65,66 @@ type stackRevisionPathParams struct {
 	RevisionID string `json:"revision_id"`
 }
 
+// swagger:parameters removeApplication
+type stackRemoveApplicationPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the application
+	// in: path
+	// required: true
+	AppResourceName string `json:"app_resource_name"`
+}
+
+// swagger:parameters removeEnvGroup
+type stackRemoveEnvGroupPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the environment group
+	// in: path
+	// required: true
+	EnvGroupName string `json:"env_group_name"`
+}
+
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1StackScopedRoutes,
@@ -538,5 +598,227 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application -> stack.NewStackAddApplicationHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application addApplication
+	//
+	// Adds an application to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an application to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddApplicationToStack
+	//     description: The application to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackAppResourceRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the application to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_application",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addApplicationHandler := stack.NewStackAddApplicationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addApplicationEndpoint,
+		Handler:  addApplicationHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} -> stack.NewStackRemoveApplicationHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} removeApplication
+	//
+	// Removes an existing application from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an application from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: app_resource_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the application from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_application/{app_resource_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeApplicationHandler := stack.NewStackRemoveApplicationHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeApplicationEndpoint,
+		Handler:  removeApplicationHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group -> stack.NewStackAddEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group addEnvGroup
+	//
+	// Adds an environment group to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an environment group to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddEnvGroupToStack
+	//     description: The environment group to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackEnvGroupRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the environment group to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_env_group",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addEnvGroupHandler := stack.NewStackAddEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addEnvGroupEndpoint,
+		Handler:  addEnvGroupHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} -> stack.NewStackRemoveEnvGroupHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} removeEnvGroup
+	//
+	// Removes an existing environment group from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an environment group from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: env_group_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the environment group from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_env_group/{env_group_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeEnvGroupHandler := stack.NewStackRemoveEnvGroupHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeEnvGroupEndpoint,
+		Handler:  removeEnvGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 1
api/server/shared/commonutils/git_utils.go

@@ -39,7 +39,7 @@ func GetLatestWorkflowRun(client *github.Client, owner, repo, filename, branch s
 		return nil, err
 	}
 
-	if workflowRuns == nil || workflowRuns.GetTotalCount() == 0 {
+	if workflowRuns == nil || workflowRuns.GetTotalCount() == 0 || len(workflowRuns.WorkflowRuns) == 0 {
 		return nil, ErrNoWorkflowRuns
 	}
 

+ 4 - 3
api/server/shared/config/env/envconfs.go

@@ -62,9 +62,10 @@ type ServerConf struct {
 	SlackClientID     string `env:"SLACK_CLIENT_ID"`
 	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
 
-	IronPlansAPIKey    string `env:"IRON_PLANS_API_KEY"`
-	IronPlansServerURL string `env:"IRON_PLANS_SERVER_URL"`
-	WhitelistedUsers   []uint `env:"WHITELISTED_USERS"`
+	BillingPrivateKey       string `env:"BILLING_PRIVATE_KEY"`
+	BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`
+	BillingPublicServerURL  string `env:"BILLING_PUBLIC_URL"`
+	WhitelistedUsers        []uint `env:"WHITELISTED_USERS"`
 
 	DOClientID     string `env:"DO_CLIENT_ID"`
 	DOClientSecret string `env:"DO_CLIENT_SECRET"`

+ 6 - 7
api/server/shared/config/loader/init_ee.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package loader
@@ -6,7 +7,6 @@ import (
 	eeBilling "github.com/porter-dev/porter/ee/billing"
 	"github.com/porter-dev/porter/ee/integrations/vault"
 	"github.com/porter-dev/porter/ee/models"
-	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
 	"github.com/porter-dev/porter/internal/billing"
 )
 
@@ -24,14 +24,13 @@ func init() {
 		key[i] = b
 	}
 
-	eeRepo := eeGorm.NewEERepository(InstanceDB, &key)
-
-	if InstanceEnvConf.ServerConf.IronPlansAPIKey != "" && InstanceEnvConf.ServerConf.IronPlansServerURL != "" {
-		serverURL := InstanceEnvConf.ServerConf.IronPlansServerURL
-		apiKey := InstanceEnvConf.ServerConf.IronPlansAPIKey
+	if InstanceEnvConf.ServerConf.BillingPrivateServerURL != "" && InstanceEnvConf.ServerConf.BillingPrivateKey != "" && InstanceEnvConf.ServerConf.BillingPublicServerURL != "" {
+		serverURL := InstanceEnvConf.ServerConf.BillingPrivateServerURL
+		publicServerURL := InstanceEnvConf.ServerConf.BillingPublicServerURL
+		apiKey := InstanceEnvConf.ServerConf.BillingPrivateKey
 		var err error
 
-		InstanceBillingManager, err = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+		InstanceBillingManager, err = eeBilling.NewClient(serverURL, publicServerURL, apiKey)
 
 		if err != nil {
 			panic(err)

+ 5 - 0
api/types/cluster.go

@@ -29,6 +29,9 @@ type Cluster struct {
 
 	// (optional) The aws integration id, if available
 	AWSIntegrationID uint `json:"aws_integration_id"`
+
+	// (optional) The aws cluster id, if available
+	AWSClusterID string `json:"aws_cluster_id,omitempty"`
 }
 
 type ClusterCandidate struct {
@@ -260,6 +263,8 @@ type CreateClusterCandidateRequest struct {
 
 type UpdateClusterRequest struct {
 	Name string `json:"name" form:"required"`
+
+	AWSClusterID string `json:"aws_cluster_id"`
 }
 
 type ListClusterResponse []*Cluster

+ 7 - 0
api/types/project.go

@@ -11,6 +11,13 @@ type Project struct {
 	StacksEnabled       bool    `json:"stacks_enabled"`
 }
 
+type FeatureFlags struct {
+	PreviewEnvironmentsEnabled string `json:"preview_environments_enabled,omitempty"`
+	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
+	StacksEnabled              string `json:"stacks_enabled,omitempty"`
+	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
+}
+
 type CreateProjectRequest struct {
 	Name string `json:"name" form:"required"`
 }

+ 19 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -11,6 +11,9 @@ const ClusterSettings: React.FC = () => {
   const [newClusterName, setNewClusterName] = useState<string>(
     context.currentCluster.name
   );
+  const [newAWSClusterID, setNewAWSClusterID] = useState<string>(
+    context.currentCluster.aws_cluster_id
+  );
   const [successfulRename, setSuccessfulRename] = useState<boolean>(false);
 
   const [accessKeyId, setAccessKeyId] = useState<string>("");
@@ -46,6 +49,7 @@ const ClusterSettings: React.FC = () => {
         "<token>",
         {
           name: newClusterName,
+          aws_cluster_id: newAWSClusterID,
         },
         {
           project_id: context.currentProject.id,
@@ -143,6 +147,20 @@ const ClusterSettings: React.FC = () => {
     }
   }
 
+  let overrideAWSClusterNameSection =
+    context.currentCluster?.aws_integration_id &&
+    context.currentCluster?.aws_integration_id != 0 ? (
+      <InputRow
+        type="text"
+        value={newAWSClusterID}
+        setValue={(x: string) => setNewAWSClusterID(x)}
+        label="AWS Cluster ID"
+        placeholder="ex: my-awesome-cluster"
+        width="100%"
+        isRequired={false}
+      />
+    ) : null;
+
   let renameClusterSection = (
     <div>
       <Heading>Rename Cluster</Heading>
@@ -155,6 +173,7 @@ const ClusterSettings: React.FC = () => {
         width="100%"
         isRequired={true}
       />
+      {overrideAWSClusterNameSection}
       <Button color="#616FEEcc" onClick={updateClusterName}>
         Submit
       </Button>

+ 20 - 25
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -100,36 +100,31 @@ const DeploymentList = () => {
 
   useEffect(() => {
     let isSubscribed = true;
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
+    setIsLoading(true);
+
+    Promise.allSettled([getPRDeploymentList(), getEnvironment()]).then(
+      ([getDeploymentsResponse, getEnvironmentResponse]) => {
+        const deploymentList =
+          getDeploymentsResponse.status === "fulfilled"
+            ? getDeploymentsResponse.value.data
+            : {};
+        const environmentList =
+          getEnvironmentResponse.status === "fulfilled"
+            ? getEnvironmentResponse.value.data
+            : {};
 
-        setDeploymentList(data.deployments || []);
-        setPullRequests(data.pull_requests || []);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-    getEnvironment()
-      .then(({ data }) => {
         if (!isSubscribed) {
           return;
         }
 
-        setNewCommentsDisabled(data.new_comments_disabled || false);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-    setIsLoading(false);
+        setDeploymentList(deploymentList.deployments || []);
+        setPullRequests(deploymentList.pull_requests || []);
+
+        setNewCommentsDisabled(environmentList.new_comments_disabled || false);
+
+        setIsLoading(false);
+      }
+    );
 
     return () => {
       isSubscribed = false;

+ 19 - 13
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -71,12 +71,12 @@ class ProjectSettings extends Component<PropsType, StateType> {
     });
 
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
-      if (this.context?.hasBillingEnabled) {
-        tabOptions.push({
-          value: "billing",
-          label: "Billing",
-        });
-      }
+      // if (this.context?.hasBillingEnabled) {
+      //   tabOptions.push({
+      //     value: "billing",
+      //     label: "Billing",
+      //   });
+      // }
 
       if (currentProject?.api_tokens_enabled) {
         tabOptions.push({
@@ -104,12 +104,12 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
-    if (
-      this.state.currentTab === "billing" &&
-      this.context?.hasBillingEnabled
-    ) {
-      return <BillingPage />;
-    }
+    // if (
+    //   this.state.currentTab === "billing" &&
+    //   this.context?.hasBillingEnabled
+    // ) {
+    //   return <BillingPage />;
+    // }
 
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
@@ -119,7 +119,13 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return (
         <Placeholder>
           <Helper>
-          Please contact <a href="mailto:support@porter.run">support@porter.run</a> to upgrade your project's usage limits.
+            Visit the{" "}
+            <a
+              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+            >
+              billing portal
+            </a>{" "}
+            to view plans.
           </Helper>
         </Placeholder>
       );

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

@@ -99,6 +99,7 @@ const overwriteAWSIntegration = baseApi<
 const updateClusterName = baseApi<
   {
     name: string;
+    aws_cluster_id?: string;
   },
   {
     project_id: number;

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

@@ -8,6 +8,7 @@ export interface ClusterType {
   infra_id?: number;
   service?: string;
   aws_integration_id?: number;
+  aws_cluster_id?: string;
 }
 
 export interface DetailedClusterType extends ClusterType {

+ 0 - 163
ee/api/server/handlers/billing/add_project.go

@@ -1,163 +0,0 @@
-package billing
-
-import (
-	"errors"
-	"fmt"
-	"net/http"
-	"strings"
-
-	"github.com/porter-dev/porter/api/server/authz"
-	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
-	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/models"
-	"gorm.io/gorm"
-)
-
-type BillingAddProjectHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewBillingAddProjectHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler {
-	return &BillingAddProjectHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
-	}
-}
-
-// Adds a project to a billing team in IronPlans. Takes the following steps:
-// 1. Looks for project billing data for the given project.
-// 2. Checks for project billing data. If the project already has billing data, move to step 3b, otherwise 3a.
-// 3a. Creates a new team in IronPlans, and creates a custom plan in IronPlans. Subscribes the team to the plan.
-// 3b. Finds the relevant team in IronPlans, creates a custom plan, and updates the subscription for the team.
-// 4. If team was created, creates ProjectBilling object.
-// 5. If team was created, finds all roles in the team. Adds all roles as a team member to the project billing. Updates UserBilling models.
-func (c *BillingAddProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// validation for internal token
-	// if internal token is empty, throw forbidden error; this server is misconfigured
-	if c.Config().ServerConf.RetoolToken == "" {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("internal retool token does not exist: re-configure the server")))
-		return
-	}
-
-	reqToken := r.Header.Get("Authorization")
-	splitToken := strings.Split(reqToken, "Bearer")
-
-	if len(splitToken) != 2 {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("no token found")))
-		return
-	}
-
-	reqToken = strings.TrimSpace(splitToken[1])
-
-	if reqToken != c.Config().ServerConf.RetoolToken {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("passed retool token does not match env")))
-		return
-	}
-
-	request := &types.AddProjectBillingRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// make sure the project exists; if it does not exist, throw forbidden error
-	proj, err := c.Repo().Project().ReadProject(request.ProjectID)
-
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-			return
-		}
-
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// look for project billing data for the given project
-	teamID, err := c.Config().BillingManager.GetTeamID(proj)
-	isNotFound := err != nil && errors.Is(err, gorm.ErrRecordNotFound)
-
-	// if the error is not nil and is not "ErrRecordNotFound", throw error
-	if err != nil && !isNotFound {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// if the team is not found, create a new team
-	if isNotFound {
-		teamID, err = c.Config().BillingManager.CreateTeam(proj)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	// determine whether to place the team on a custom plan or an existing plan
-	if request.ExistingPlanName != "" {
-		err = addToExistingPlan(c.Config(), request.ExistingPlanName, teamID)
-	} else {
-		err = addToCustomPlan(c.Config(), teamID, proj, request)
-	}
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// add users in project to the plan
-	projRoles, err := c.Repo().Project().ListProjectRoles(proj.ID)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	for _, role := range projRoles {
-		user, err := c.Repo().User().ReadUser(role.UserID)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-
-		err = c.Config().BillingManager.AddUserToTeam(teamID, user, &role)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	w.WriteHeader(http.StatusOK)
-}
-
-func addToCustomPlan(c *config.Config, teamID string, proj *models.Project, req *types.AddProjectBillingRequest) error {
-	// create a new plan in IronPlans
-	planID, err := c.BillingManager.CreatePlan(teamID, proj, req)
-
-	if err != nil {
-		return err
-	}
-
-	// create a new subscription to this plan in IronPlans
-	return c.BillingManager.CreateOrUpdateSubscription(teamID, planID)
-}
-
-func addToExistingPlan(c *config.Config, existingPlanName, teamID string) error {
-	// look for existing plans in IronPlans
-	planID, err := c.BillingManager.GetExistingPublicPlan(existingPlanName)
-
-	if err != nil {
-		return err
-	}
-
-	// create a new subscription to this plan in IronPlans
-	return c.BillingManager.CreateOrUpdateSubscription(teamID, planID)
-}

+ 0 - 66
ee/api/server/handlers/billing/get_token.go

@@ -1,66 +0,0 @@
-package billing
-
-import (
-	"fmt"
-	"net/http"
-
-	"github.com/porter-dev/porter/api/server/authz"
-	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/shared"
-	"github.com/porter-dev/porter/api/server/shared/apierrors"
-	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-type BillingGetTokenHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewBillingGetTokenHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) http.Handler {
-	return &BillingGetTokenHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *BillingGetTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
-
-	// we double-check that the user is an admin the project
-	roles, err := c.Repo().Project().ListProjectRoles(proj.ID)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	for _, role := range roles {
-		if role.UserID != 0 && role.UserID == user.ID {
-			if role.Kind != types.RoleAdmin {
-				c.HandleAPIError(w, r, apierrors.NewErrForbidden(
-					fmt.Errorf("user %d is not an admin in project %d", user.ID, proj.ID),
-				))
-
-				return
-			}
-		}
-	}
-
-	token, teamID, err := c.Config().BillingManager.GetIDToken(proj, user)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	c.WriteResult(w, r, &types.GetBillingTokenResponse{
-		Token:  token,
-		TeamID: teamID,
-	})
-}

+ 33 - 1
ee/api/server/handlers/billing/webhook.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"strconv"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -48,7 +49,7 @@ func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	// parse usage and update project
-	newUsage, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
+	newUsage, features, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -81,4 +82,35 @@ func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
+
+	// update the feature flags
+	project, err := c.Repo().Project().ReadProject(newUsage.ProjectID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if managedDatabasesEnabled, err := strconv.ParseBool(features.ManagedDatabasesEnabled); err == nil {
+		project.RDSDatabasesEnabled = managedDatabasesEnabled
+	}
+
+	if managedInfraEnabled, err := strconv.ParseBool(features.ManagedInfraEnabled); err == nil {
+		project.ManagedInfraEnabled = managedInfraEnabled
+	}
+
+	if stacksEnabled, err := strconv.ParseBool(features.StacksEnabled); err == nil {
+		project.StacksEnabled = stacksEnabled
+	}
+
+	if previewEnvsEnabled, err := strconv.ParseBool(features.PreviewEnvironmentsEnabled); err == nil {
+		project.PreviewEnvsEnabled = previewEnvsEnabled
+	}
+
+	_, err = c.Repo().Project().UpdateProject(project)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }

+ 1 - 19
ee/api/server/handlers/invite/accept.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package invite
@@ -104,24 +105,5 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// add project to billing team
-	teamID, err := c.Config().BillingManager.GetTeamID(proj)
-
-	if err != nil {
-		// we do not write error response, since setting up billing error can be
-		// resolved later and may not be fatal
-		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-	}
-
-	if teamID != "" {
-		err = c.Config().BillingManager.AddUserToTeam(teamID, user, role)
-
-		if err != nil {
-			// we do not write error response, since setting up billing error can be
-			// resolved later and may not be fatal
-			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
-		}
-	}
-
 	http.Redirect(w, r, "/dashboard", 302)
 }

+ 293 - 0
ee/billing/client.go

@@ -0,0 +1,293 @@
+//go:build ee
+// +build ee
+
+package billing
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/gorilla/schema"
+	"github.com/porter-dev/porter/api/types"
+	cemodels "github.com/porter-dev/porter/internal/models"
+)
+
+// Client contains an API client for the internal billing engine
+type Client struct {
+	apiKey          string
+	serverURL       string
+	publicServerURL string
+	httpClient      *http.Client
+}
+
+// NewClient creates a new billing API client
+func NewClient(serverURL, publicServerURL, apiKey string) (*Client, error) {
+	httpClient := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	client := &Client{apiKey, serverURL, publicServerURL, httpClient}
+
+	return client, nil
+}
+
+func (c *Client) CreateTeam(user *cemodels.User, proj *cemodels.Project) (string, error) {
+	// call the internal billing endpoint to create a new customer in the database
+	reqData := &CreateCustomerRequest{
+		Email:       user.Email,
+		UserID:      user.ID,
+		ProjectID:   proj.ID,
+		ProjectName: proj.Name,
+	}
+
+	err := c.postRequest("/api/v1/private/customer", reqData, nil)
+
+	if err != nil {
+		return "", err
+	}
+
+	return fmt.Sprintf("%d-%d", proj.ID, user.ID), nil
+}
+
+func (c *Client) DeleteTeam(user *cemodels.User, proj *cemodels.Project) error {
+	// call delete customer
+	reqData := &DeleteCustomerRequest{
+		UserID:    user.ID,
+		ProjectID: proj.ID,
+	}
+
+	return c.deleteRequest("/api/v1/private/customer", reqData, nil)
+}
+
+func (c *Client) GetRedirectURI(user *cemodels.User, proj *cemodels.Project) (string, error) {
+	// get an internal cookie
+	reqData := &CreateBillingCookieRequest{
+		ProjectName: proj.Name,
+		ProjectID:   proj.ID,
+		UserID:      user.ID,
+		Email:       user.Email,
+	}
+
+	createCookieVals := make(map[string][]string)
+	err := schema.NewEncoder().Encode(reqData, createCookieVals)
+
+	if err != nil {
+		return "", err
+	}
+
+	urlVals := url.Values(createCookieVals)
+	encodedURLVals := urlVals.Encode()
+
+	dst := &CreateBillingCookieResponse{}
+
+	err = c.postRequest("/api/v1/private/cookie", reqData, dst)
+
+	if err != nil {
+		return "", err
+	}
+
+	redirectData := &VerifyUserRequest{
+		TokenID: dst.TokenID,
+		Token:   dst.Token,
+	}
+
+	vals := make(map[string][]string)
+	err = schema.NewEncoder().Encode(redirectData, vals)
+
+	if err != nil {
+		return "", err
+	}
+
+	urlVals = url.Values(vals)
+	encodedURLVals = urlVals.Encode()
+
+	return fmt.Sprintf("%s/api/v1/verify?%s", c.publicServerURL, encodedURLVals), nil
+}
+
+// VerifySignature verifies a webhook signature based on hmac protocol
+func (c *Client) VerifySignature(signature string, body []byte) bool {
+	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
+		return false
+	}
+
+	actual := make([]byte, 32)
+	_, err := hex.Decode(actual, []byte(signature[7:]))
+
+	if err != nil {
+		return false
+	}
+
+	computed := hmac.New(sha256.New, []byte(c.apiKey))
+	_, err = computed.Write(body)
+
+	if err != nil {
+		return false
+	}
+
+	return hmac.Equal(computed.Sum(nil), actual)
+}
+
+func (c *Client) postRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("POST", path, data, dst)
+}
+
+func (c *Client) putRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("PUT", path, data, dst)
+}
+
+func (c *Client) deleteRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("DELETE", path, data, dst)
+}
+
+func (c *Client) getRequest(path string, dst interface{}, query ...map[string]string) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	q := reqURL.Query()
+	for _, queryGroup := range query {
+		for key, val := range queryGroup {
+			q.Add(key, val)
+		}
+	}
+
+	reqURL.RawQuery = q.Encode()
+
+	req, err := http.NewRequest(
+		"GET",
+		reqURL.String(),
+		nil,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	if dst != nil {
+		return json.NewDecoder(res.Body).Decode(dst)
+	}
+
+	return nil
+}
+
+func (c *Client) writeRequest(method, path string, data interface{}, dst interface{}) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	var strData []byte
+
+	if data != nil {
+		strData, err = json.Marshal(data)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	req, err := http.NewRequest(
+		method,
+		reqURL.String(),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	if dst != nil {
+		return json.NewDecoder(res.Body).Decode(dst)
+	}
+
+	return nil
+}
+
+const (
+	FeatureSlugCPU      string = "cpu"
+	FeatureSlugMemory   string = "memory"
+	FeatureSlugClusters string = "clusters"
+	FeatureSlugUsers    string = "users"
+)
+
+func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.ProjectUsage, *types.FeatureFlags, error) {
+	usageData := &APIWebhookRequest{}
+
+	err := json.Unmarshal(payload, usageData)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return &cemodels.ProjectUsage{
+			ProjectID:      usageData.ProjectID,
+			ResourceCPU:    usageData.CPU,
+			ResourceMemory: usageData.Memory * 1000,
+			Clusters:       usageData.Clusters,
+			Users:          usageData.Users,
+		}, &types.FeatureFlags{
+			PreviewEnvironmentsEnabled: usageData.PreviewEnvironmentsEnabled,
+			ManagedInfraEnabled:        usageData.ManagedInfraEnabled,
+			StacksEnabled:              usageData.StacksEnabled,
+			ManagedDatabasesEnabled:    usageData.ManagedDatabasesEnabled,
+		}, nil
+}

+ 0 - 677
ee/billing/ironplans.go

@@ -1,677 +0,0 @@
-// +build ee
-
-package billing
-
-import (
-	"crypto/hmac"
-	"crypto/sha256"
-	"encoding/base64"
-	"encoding/hex"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"net/url"
-	"strings"
-	"time"
-
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/ee/models"
-	"github.com/porter-dev/porter/ee/repository"
-	"gorm.io/gorm"
-
-	cemodels "github.com/porter-dev/porter/internal/models"
-)
-
-// Client contains an API client for IronPlans
-type Client struct {
-	apiKey    string
-	serverURL string
-	repo      repository.EERepository
-
-	httpClient *http.Client
-
-	defaultPlanID string
-	customPlanID  string
-}
-
-// NewClient creates a new billing API client
-func NewClient(serverURL, apiKey string, repo repository.EERepository) (*Client, error) {
-	httpClient := &http.Client{
-		Timeout: time.Minute,
-	}
-
-	client := &Client{apiKey, serverURL, repo, httpClient, "", ""}
-
-	// get the default plans from the IronPlans API server
-	defPlanID, err := client.GetExistingPublicPlan("Free")
-
-	if err != nil {
-		return nil, err
-	}
-
-	customPlanID, err := client.GetExistingPublicPlan("Enterprise")
-
-	if err != nil {
-		return nil, err
-	}
-
-	client.defaultPlanID = defPlanID
-	client.customPlanID = customPlanID
-
-	return client, nil
-}
-
-func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
-	resp := &Team{}
-	err := c.postRequest("/teams/v1", &CreateTeamRequest{
-		Name: proj.Name,
-	}, resp)
-
-	if err != nil {
-		return "", err
-	}
-
-	// put the user on the free plan, as the default behavior, if there is a default plan
-	if c.defaultPlanID != "" {
-		err = c.CreateOrUpdateSubscription(resp.ID, c.defaultPlanID)
-
-		if err != nil {
-			return "", fmt.Errorf("subscription creation failed: %s", err)
-		}
-	}
-
-	_, err = c.repo.ProjectBilling().CreateProjectBilling(&models.ProjectBilling{
-		ProjectID:     proj.ID,
-		BillingTeamID: resp.ID,
-	})
-
-	if err != nil {
-		return "", err
-	}
-
-	return resp.ID, err
-}
-
-func (c *Client) DeleteTeam(proj *cemodels.Project) error {
-	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
-
-	if err != nil {
-		return err
-	}
-
-	return c.deleteRequest(fmt.Sprintf("/teams/v1/%s", projBilling.BillingTeamID), nil, nil)
-}
-
-func (c *Client) GetTeamID(proj *cemodels.Project) (teamID string, err error) {
-	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
-
-	if err != nil {
-		return "", err
-	}
-
-	return projBilling.BillingTeamID, nil
-}
-
-func (c *Client) CreatePlan(teamID string, proj *cemodels.Project, planSpec *types.AddProjectBillingRequest) (string, error) {
-	// construct basic plan object
-	planFeatures := make([]*CreatePlanFeature, 0)
-
-	userDisplay := fmt.Sprintf("Up to %d users", planSpec.Users)
-
-	if planSpec.Users == 0 {
-		userDisplay = fmt.Sprintf("Unlimited users")
-	}
-
-	clusterDisplay := fmt.Sprintf("Up to %d clusters", planSpec.Clusters)
-
-	if planSpec.Clusters == 0 {
-		clusterDisplay = fmt.Sprintf("Unlimited clusters")
-	}
-
-	cpuDisplay := fmt.Sprintf("Up to %d CPUs", planSpec.CPU)
-
-	if planSpec.CPU == 0 {
-		cpuDisplay = fmt.Sprintf("Unlimited CPU")
-	}
-
-	ramDisplay := fmt.Sprintf("Up to %d GB RAM", planSpec.Memory)
-
-	if planSpec.Memory == 0 {
-		ramDisplay = fmt.Sprintf("Unlimited RAM")
-	}
-
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: userDisplay,
-	})
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: clusterDisplay,
-	})
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: cpuDisplay,
-	})
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: ramDisplay,
-	})
-
-	var customPlanID *string
-
-	if c.customPlanID != "" {
-		customPlanID = &c.customPlanID
-	}
-
-	createPlanReq := &CreatePlanRequest{
-		Name:               proj.Name,
-		IsActive:           true,
-		IsPublic:           false,
-		IsTrialAllowed:     true,
-		ReplacePlanID:      customPlanID,
-		PerMonthPriceCents: planSpec.Price,
-		PerYearPriceCents:  12 * planSpec.Price,
-		Features:           planFeatures,
-		TeamsAccess: []*CreatePlanTeamsAccess{
-			{
-				TeamID: teamID,
-				Revoke: false,
-			},
-		},
-	}
-
-	// find all relevant feature IDs
-	listResp := &ListFeaturesResponse{}
-	err := c.getRequest("/features/v1", listResp)
-
-	if err != nil {
-		return "", err
-	}
-
-	// create a feature spec per feature ID, and add to features array for plan
-	for _, feature := range listResp.Results {
-		featureSpec := &CreateFeatureSpecRequest{
-			Name:         "unnamed",
-			RecordPeriod: "monthly",
-			Aggregation:  "sum",
-			UnitPrice:    0,
-		}
-
-		switch feature.Slug {
-		case FeatureSlugUsers:
-			featureSpec.MaxLimit = planSpec.Users
-			featureSpec.UnitsIncluded = planSpec.Users
-		case FeatureSlugClusters:
-			featureSpec.MaxLimit = planSpec.Clusters
-			featureSpec.UnitsIncluded = planSpec.Clusters
-		case FeatureSlugCPU:
-			featureSpec.MaxLimit = planSpec.CPU
-			featureSpec.UnitsIncluded = planSpec.CPU
-		case FeatureSlugMemory:
-			featureSpec.MaxLimit = planSpec.Memory
-			featureSpec.UnitsIncluded = planSpec.Memory
-		// continue on default behavior so that feature spec is not created for
-		// features that don't match a slug
-		default:
-			continue
-		}
-
-		// create the feature spec
-		resp := &CreateFeaturespecResponse{}
-		err = c.postRequest("/featurespecs/v1/", featureSpec, resp)
-
-		if err != nil {
-			return "", err
-		}
-
-		var index int
-		switch feature.Slug {
-		case FeatureSlugUsers:
-			index = 0
-		case FeatureSlugClusters:
-			index = 1
-		case FeatureSlugCPU:
-			index = 2
-		case FeatureSlugMemory:
-			index = 3
-		}
-
-		createPlanReq.Features[index].FeatureID = feature.ID
-		createPlanReq.Features[index].SpecID = resp.ID
-	}
-
-	// create the plan and return the plan ID
-	planResp := &Plan{}
-
-	err = c.postRequest("/plans/v1/", createPlanReq, planResp)
-
-	if err != nil {
-		return "", err
-	}
-
-	return planResp.ID, nil
-}
-
-func (c *Client) CreateOrUpdateSubscription(teamID, planID string) error {
-	// determine if subscription already exists by reading the team ID and seeing if the subscription
-	// field has an ID attached
-	teamResp := &Team{}
-	err := c.getRequest(fmt.Sprintf("/teams/v1/%s", teamID), teamResp)
-
-	if err != nil {
-		return err
-	}
-
-	subReq := &CreateSubscriptionRequest{
-		PlanID:     planID,
-		NextPlanID: c.defaultPlanID,
-		TeamID:     teamID,
-		IsPaused:   false,
-	}
-
-	// if subscription ID is not empty, perform a PUT request to update the subscription
-	if teamResp.Subscription.ID != "" {
-		// delete the subscription
-		err = c.deleteRequest(fmt.Sprintf("/subscriptions/v1/%s/purge/", teamResp.Subscription.ID), nil, nil)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	return c.postRequest("/subscriptions/v1", subReq, nil)
-}
-
-func (c *Client) GetExistingPublicPlan(planName string) (string, error) {
-	listResp := &ListPlansResponse{}
-	err := c.getRequest("/plans/v1/", listResp, map[string]string{"is_public": "true"})
-
-	if err != nil {
-		return "", err
-	}
-
-	for _, plan := range listResp.Results {
-		if plan.Name == planName {
-			return plan.ID, nil
-		}
-	}
-
-	return "", fmt.Errorf("plan not found")
-}
-
-func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
-	// determine if user is already in team/has user billing
-	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, user.ID)
-
-	if userBilling != nil {
-		return nil
-	}
-
-	roleEnum := RoleEnumMember
-
-	// if user's role is admin, add them to the team as an owner
-	if role.Kind == types.RoleAdmin {
-		roleEnum = RoleEnumOwner
-	}
-
-	req := &AddTeammateRequest{
-		TeamID:   teamID,
-		Role:     roleEnum,
-		Email:    user.Email,
-		SourceID: fmt.Sprintf("%d-%d", role.ProjectID, user.ID),
-	}
-
-	resp := &Teammate{}
-
-	err = c.postRequest("/team_memberships/v1", req, resp)
-
-	if err != nil {
-		return err
-	}
-
-	_, err = c.repo.UserBilling().CreateUserBilling(&models.UserBilling{
-		ProjectID:  role.ProjectID,
-		UserID:     user.ID,
-		TeammateID: resp.ID,
-		Token:      []byte(""),
-	})
-
-	return err
-}
-
-func (c *Client) UpdateUserInTeam(role *cemodels.Role) error {
-	// get the user billing information to get the membership id
-	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, role.UserID)
-
-	if err != nil {
-		return err
-	}
-
-	roleEnum := RoleEnumMember
-
-	// if user's role is admin, add them to the team as an owner
-	if role.Kind == types.RoleAdmin {
-		roleEnum = RoleEnumOwner
-	}
-
-	req := &UpdateTeammateRequest{
-		Role: roleEnum,
-	}
-
-	resp := &Teammate{}
-
-	return c.putRequest(fmt.Sprintf("/team_memberships/v1/%s", userBilling.TeammateID), req, resp)
-}
-
-func (c *Client) RemoveUserFromTeam(role *cemodels.Role) error {
-	// get the user billing information to get the membership id
-	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, role.UserID)
-
-	if err != nil {
-		return err
-	}
-
-	return c.deleteRequest(fmt.Sprintf("/team_memberships/v1/%s", userBilling.TeammateID), nil, nil)
-}
-
-// GetIDToken gets an id token for a user in a project, creating the ID token if necessary
-func (c *Client) GetIDToken(proj *cemodels.Project, user *cemodels.User) (token string, teamID string, err error) {
-	// attempt to get a team ID for the project
-	teamID, err = c.GetTeamID(proj)
-
-	// attempt to read the user billing data from the project
-	userBilling, err := c.repo.UserBilling().ReadUserBilling(proj.ID, user.ID)
-	notFound := errors.Is(err, gorm.ErrRecordNotFound)
-
-	if !notFound && err != nil {
-		return "", "", err
-	}
-
-	if !notFound {
-		token = string(userBilling.Token)
-
-		if token != "" {
-			// check if the JWT token has expired
-			isTokExpired := isExpired(token)
-
-			// if JWT token has not expired, return the token
-			if !isTokExpired {
-				return token, teamID, nil
-			}
-		}
-	}
-
-	req := &CreateIDTokenRequest{
-		Email:  user.Email,
-		UserID: fmt.Sprintf("%d-%d", proj.ID, user.ID),
-	}
-
-	resp := &CreateIDTokenResponse{}
-
-	err = c.postRequest("/customers/v1/token", req, resp)
-
-	if err != nil {
-		return "", "", err
-	}
-
-	token = resp.Token
-
-	if notFound {
-		_, err := c.repo.UserBilling().CreateUserBilling(&models.UserBilling{
-			ProjectID: proj.ID,
-			UserID:    user.ID,
-			Token:     []byte(token),
-		})
-
-		if err != nil {
-			return "", "", err
-		}
-	} else {
-		_, err := c.repo.UserBilling().UpdateUserBilling(&models.UserBilling{
-			Model: &gorm.Model{
-				ID: userBilling.ID,
-			},
-			ProjectID:  proj.ID,
-			UserID:     user.ID,
-			Token:      []byte(token),
-			TeammateID: userBilling.TeammateID,
-		})
-
-		if err != nil {
-			return "", "", err
-		}
-	}
-
-	return token, teamID, nil
-}
-
-// VerifySignature verifies a webhook signature based on hmac protocol
-// https://docs.ironplans.com/webhook-events/webhook-events
-func (c *Client) VerifySignature(signature string, body []byte) bool {
-	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
-		return false
-	}
-
-	actual := make([]byte, 32)
-	_, err := hex.Decode(actual, []byte(signature[7:]))
-
-	if err != nil {
-		return false
-	}
-
-	computed := hmac.New(sha256.New, []byte(c.apiKey))
-	_, err = computed.Write(body)
-
-	if err != nil {
-		return false
-	}
-
-	return hmac.Equal(computed.Sum(nil), actual)
-}
-
-func (c *Client) postRequest(path string, data interface{}, dst interface{}) error {
-	return c.writeRequest("POST", path, data, dst)
-}
-
-func (c *Client) putRequest(path string, data interface{}, dst interface{}) error {
-	return c.writeRequest("PUT", path, data, dst)
-}
-
-func (c *Client) deleteRequest(path string, data interface{}, dst interface{}) error {
-	return c.writeRequest("DELETE", path, data, dst)
-}
-
-func (c *Client) getRequest(path string, dst interface{}, query ...map[string]string) error {
-	reqURL, err := url.Parse(c.serverURL)
-
-	if err != nil {
-		return nil
-	}
-
-	reqURL.Path = path
-
-	q := reqURL.Query()
-	for _, queryGroup := range query {
-		for key, val := range queryGroup {
-			q.Add(key, val)
-		}
-	}
-
-	reqURL.RawQuery = q.Encode()
-
-	req, err := http.NewRequest(
-		"GET",
-		reqURL.String(),
-		nil,
-	)
-
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("Content-Type", "application/json; charset=utf-8")
-	req.Header.Set("Accept", "application/json; charset=utf-8")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
-
-	res, err := c.httpClient.Do(req)
-
-	if err != nil {
-		return err
-	}
-
-	defer res.Body.Close()
-
-	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
-		resBytes, err := ioutil.ReadAll(res.Body)
-
-		if err != nil {
-			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
-		}
-
-		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
-	}
-
-	if dst != nil {
-		return json.NewDecoder(res.Body).Decode(dst)
-	}
-
-	return nil
-}
-
-func (c *Client) writeRequest(method, path string, data interface{}, dst interface{}) error {
-	reqURL, err := url.Parse(c.serverURL)
-
-	if err != nil {
-		return nil
-	}
-
-	reqURL.Path = path
-
-	var strData []byte
-
-	if data != nil {
-		strData, err = json.Marshal(data)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	req, err := http.NewRequest(
-		method,
-		reqURL.String(),
-		strings.NewReader(string(strData)),
-	)
-
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("Content-Type", "application/json; charset=utf-8")
-	req.Header.Set("Accept", "application/json; charset=utf-8")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
-
-	res, err := c.httpClient.Do(req)
-
-	if err != nil {
-		return err
-	}
-
-	defer res.Body.Close()
-
-	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
-		resBytes, err := ioutil.ReadAll(res.Body)
-
-		if err != nil {
-			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
-		}
-
-		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
-	}
-
-	if dst != nil {
-		return json.NewDecoder(res.Body).Decode(dst)
-	}
-
-	return nil
-}
-
-const (
-	FeatureSlugCPU      string = "cpu"
-	FeatureSlugMemory   string = "memory"
-	FeatureSlugClusters string = "clusters"
-	FeatureSlugUsers    string = "users"
-)
-
-func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.ProjectUsage, error) {
-	subscription := &SubscriptionWebhookRequest{}
-
-	err := json.Unmarshal(payload, subscription)
-
-	if err != nil {
-		return nil, err
-	}
-
-	// if event type is not subscription, return wrong webhook event type error
-	if subscription.EventType != "subscription" {
-		return nil, nil
-	}
-
-	// get the project id linked to that team
-	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByTeamID(subscription.TeamID)
-
-	if err != nil {
-		return nil, err
-	}
-
-	usage := &cemodels.ProjectUsage{
-		ProjectID: projBilling.ProjectID,
-	}
-
-	for _, feature := range subscription.Plan.Features {
-		// look for slug of "cpus" and "memory"
-		maxLimit := uint(feature.FeatureSpec.MaxLimit)
-		switch feature.Feature.Slug {
-		case FeatureSlugCPU:
-			usage.ResourceCPU = maxLimit
-		case FeatureSlugMemory:
-			usage.ResourceMemory = 1000 * maxLimit
-		case FeatureSlugClusters:
-			usage.Clusters = maxLimit
-		case FeatureSlugUsers:
-			usage.Users = maxLimit
-		}
-	}
-
-	return usage, nil
-}
-
-type expiryJWT struct {
-	ExpiresAt int64 `json:"exp"`
-}
-
-func isExpired(token string) bool {
-	var encoded string
-
-	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
-		return true
-	} else {
-		encoded = tokenSplit[1]
-	}
-
-	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
-
-	if err != nil {
-		return true
-	}
-
-	expiryData := &expiryJWT{}
-
-	err = json.Unmarshal(decodedBytes, expiryData)
-
-	if err != nil {
-		return true
-	}
-
-	expiryTime := time.Unix(expiryData.ExpiresAt, 0)
-
-	return expiryTime.Before(time.Now())
-}

+ 30 - 125
ee/billing/types.go

@@ -1,142 +1,47 @@
+//go:build ee
 // +build ee
 
 package billing
 
-type Team struct {
-	ID           string       `json:"id"`
-	ProviderID   string       `json:"provider_id"`
-	Name         string       `json:"name"`
-	Members      []Teammate   `json:"members"`
-	Subscription Subscription `json:"subscription"`
+type CreateCustomerRequest struct {
+	Email       string `json:"email" form:"required"`
+	UserID      uint   `json:"user_id" form:"required"`
+	ProjectID   uint   `json:"project_id" form:"required"`
+	ProjectName string `json:"project_name" form:"required"`
 }
 
-type RoleEnum string
-
-const (
-	RoleEnumOwner  RoleEnum = "owner"
-	RoleEnumMember RoleEnum = "member"
-)
-
-type Teammate struct {
-	ID         string   `json:"id"`
-	CustomerID string   `json:"customer_id"`
-	Role       RoleEnum `json:"role"`
-	Email      string   `json:"email"`
-}
-
-type Subscription struct {
-	ID       string `json:"id"`
-	Plan     Plan   `json:"plan"`
-	IsActive bool   `json:"is_active"`
-}
-
-type Plan struct {
-	ID         string        `json:"id"`
-	ProviderID string        `json:"string"`
-	Name       string        `json:"name"`
-	IsActive   bool          `json:"is_active"`
-	Features   []PlanFeature `json:"features"`
-}
-
-type CreatePlanRequest struct {
-	Name               string                   `json:"name"`
-	IsActive           bool                     `json:"is_active"`
-	IsPublic           bool                     `json:"is_public"`
-	IsTrialAllowed     bool                     `json:"is_trial_allowed"`
-	PerMonthPriceCents uint                     `json:"per_month_price_cents"`
-	PerYearPriceCents  uint                     `json:"per_year_price_cents"`
-	ReplacePlanID      *string                  `json:"replace_plan_id"`
-	Features           []*CreatePlanFeature     `json:"features"`
-	TeamsAccess        []*CreatePlanTeamsAccess `json:"teams_access"`
-}
-
-type CreatePlanFeature struct {
-	FeatureID string `json:"feature_id"`
-	SpecID    string `json:"spec_id"`
-	Display   string `json:"display"`
-	Sort      uint   `json:"sort"`
-	IsActive  bool   `json:"is_active"`
-}
-
-type CreatePlanTeamsAccess struct {
-	TeamID string `json:"team_id"`
-	Revoke bool   `json:"revoke"`
-}
-
-type CreateFeatureSpecRequest struct {
-	Name          string `json:"name"`
-	RecordPeriod  string `json:"record_period"`
-	Aggregation   string `json:"aggregation"`
-	MaxLimit      uint   `json:"max_limit"`
-	UnitPrice     uint   `json:"unit_price"`
-	UnitsIncluded uint   `json:"units_included"`
-}
-
-type CreateFeaturespecResponse struct {
-	*CreateFeatureSpecRequest
-	ID string `json:"id"`
-}
-
-type ListFeaturesResponse struct {
-	Results []Feature `json:"results"`
-}
-
-type ListPlansResponse struct {
-	Results []Plan `json:"results"`
-}
-
-type PlanFeature struct {
-	ID          string      `json:"id"`
-	IsActive    bool        `json:"is_active"`
-	Feature     Feature     `json:"feature"`
-	FeatureSpec FeatureSpec `json:"spec"`
+type DeleteCustomerRequest struct {
+	UserID    uint `json:"user_id" form:"required"`
+	ProjectID uint `json:"project_id" form:"required"`
 }
 
-type Feature struct {
-	ID   string `json:"id"`
-	Slug string `json:"slug"`
-}
+type APIWebhookRequest struct {
+	ProjectID uint `json:"project_id" form:"required"`
 
-type FeatureSpec struct {
-	ID         string `json:"id"`
-	Name       string `json:"name"`
-	MaxLimit   int64  `json:"max_limit"`
-	ProviderID string `json:"provider_id"`
-}
-
-type CreateTeamRequest struct {
-	Name string `json:"name"`
-}
-
-type AddTeammateRequest struct {
-	Role     RoleEnum `json:"role"`
-	Email    string   `json:"email"`
-	SourceID string   `json:"source_id"`
-	TeamID   string   `json:"team_id"`
-}
-
-type UpdateTeammateRequest struct {
-	Role RoleEnum `json:"role"`
-}
+	Clusters uint `json:"clusters" form:"required"`
+	Users    uint `json:"users" form:"required"`
+	CPU      uint `json:"cpu" form:"required"`
+	Memory   uint `json:"memory" form:"required"`
 
-type CreateIDTokenRequest struct {
-	Email  string `json:"customer_email"`
-	UserID string `json:"customer_source_id"`
+	PreviewEnvironmentsEnabled string `json:"preview_environments_enabled,omitempty"`
+	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
+	StacksEnabled              string `json:"stacks_enabled,omitempty"`
+	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
 }
 
-type CreateIDTokenResponse struct {
-	Token string `json:"token"`
+type CreateBillingCookieRequest struct {
+	Email       string `json:"email" form:"required"`
+	UserID      uint   `json:"user_id" form:"required"`
+	ProjectID   uint   `json:"project_id" form:"required"`
+	ProjectName string `json:"project_name" form:"required"`
 }
 
-type SubscriptionWebhookRequest struct {
-	EventType string `json:"event_type"`
-	TeamID    string `json:"team_id"`
-	Plan      Plan   `json:"plan"`
+type CreateBillingCookieResponse struct {
+	Token   string `json:"token"`
+	TokenID string `json:"token_id"`
 }
 
-type CreateSubscriptionRequest struct {
-	PlanID     string `json:"plan_id"`
-	TeamID     string `json:"team_id"`
-	IsPaused   bool   `json:"is_paused"`
-	NextPlanID string `json:"next_plan_id"`
+type VerifyUserRequest struct {
+	TokenID string `schema:"token_id" form:"required"`
+	Token   string `schema:"token" form:"required"`
 }

+ 1 - 0
go.mod

@@ -112,6 +112,7 @@ require (
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/xanzy/go-gitlab v0.68.0 // indirect
+	go.uber.org/goleak v1.1.12 // indirect
 )
 
 require (

+ 1 - 0
go.sum

@@ -2099,6 +2099,7 @@ go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
 go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=

+ 10 - 62
internal/billing/billing.go

@@ -12,41 +12,17 @@ type BillingManager interface {
 	// CreateTeam creates the concept of a billing "team". This is currently a one-to-one
 	// mapping with projects, but this may change in the future (i.e. multiple projects
 	// per same team)
-	CreateTeam(proj *models.Project) (teamID string, err error)
+	CreateTeam(user *models.User, proj *models.Project) (teamID string, err error)
 
 	// DeleteTeam deletes a billing team.
-	DeleteTeam(proj *models.Project) (err error)
+	DeleteTeam(user *models.User, proj *models.Project) (err error)
 
-	// GetTeamID gets the billing team id for a project
-	GetTeamID(proj *models.Project) (teamID string, err error)
-
-	// CreatePlan creates a new plan based on the requested limits
-	CreatePlan(teamID string, proj *models.Project, planSpec *types.AddProjectBillingRequest) (string, error)
-
-	// CreateOrUpdateSubscription creates or updates a new subscription to a plan, based on a team and plan ID
-	CreateOrUpdateSubscription(teamID, planID string) error
-
-	// GetExistingPublicPlan returns an existing public plan based on a name
-	GetExistingPublicPlan(planName string) (string, error)
-
-	// AddUserToTeam adds a user to a team, and cases on whether the user can view
-	// billing based on the role.
-	AddUserToTeam(teamID string, user *models.User, role *models.Role) error
-
-	// UpdateUserInTeam updates a user's role in a team, and cases on whether the user can view
-	// billing based on the role.
-	UpdateUserInTeam(role *models.Role) error
-
-	// RemoveUserFromTeam removes a user from a team
-	RemoveUserFromTeam(role *models.Role) error
-
-	// GetIDToken retrieves a billing token for a user. The billing token can be exchanged
-	// to view billing information.
-	GetIDToken(proj *models.Project, user *models.User) (token string, teamID string, err error)
+	// GetRedirectURI gets the redirect URI to send the user to the billing portal
+	GetRedirectURI(user *models.User, proj *models.Project) (url string, err error)
 
 	// ParseProjectUsageFromWebhook parses the project usage from a webhook payload sent
 	// from a billing agent
-	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error)
+	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, *types.FeatureFlags, error)
 
 	// VerifySignature verifies the signature for a webhook
 	VerifySignature(signature string, body []byte) bool
@@ -55,48 +31,20 @@ type BillingManager interface {
 // NoopBillingManager performs no billing operations
 type NoopBillingManager struct{}
 
-func (n *NoopBillingManager) CreateTeam(proj *models.Project) (teamID string, err error) {
+func (n *NoopBillingManager) CreateTeam(user *models.User, proj *models.Project) (teamID string, err error) {
 	return fmt.Sprintf("%d", proj.ID), nil
 }
 
-func (n *NoopBillingManager) DeleteTeam(proj *models.Project) (err error) {
+func (n *NoopBillingManager) DeleteTeam(user *models.User, proj *models.Project) (err error) {
 	return nil
 }
 
-func (n *NoopBillingManager) GetTeamID(proj *models.Project) (teamID string, err error) {
-	return fmt.Sprintf("%d", proj.ID), nil
-}
-
-func (n *NoopBillingManager) CreatePlan(teamID string, proj *models.Project, planSpec *types.AddProjectBillingRequest) (string, error) {
+func (n *NoopBillingManager) GetRedirectURI(user *models.User, proj *models.Project) (url string, err error) {
 	return "", nil
 }
 
-func (n *NoopBillingManager) CreateOrUpdateSubscription(teamID, planID string) error {
-	return nil
-}
-
-func (n *NoopBillingManager) GetExistingPublicPlan(planName string) (string, error) {
-	return "", nil
-}
-
-func (n *NoopBillingManager) AddUserToTeam(teamID string, user *models.User, role *models.Role) error {
-	return nil
-}
-
-func (n *NoopBillingManager) UpdateUserInTeam(role *models.Role) error {
-	return nil
-}
-
-func (n *NoopBillingManager) RemoveUserFromTeam(role *models.Role) error {
-	return nil
-}
-
-func (n *NoopBillingManager) GetIDToken(proj *models.Project, user *models.User) (token string, teamID string, err error) {
-	return "", "", nil
-}
-
-func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error) {
-	return nil, nil
+func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, *types.FeatureFlags, error) {
+	return nil, nil, nil
 }
 
 func (n *NoopBillingManager) VerifySignature(signature string, body []byte) bool {

+ 10 - 0
internal/helm/agent.go

@@ -141,6 +141,16 @@ func (a *Agent) GetRelease(
 	return release, err
 }
 
+// DeleteReleaseRevision deletes a specific revision of a release
+func (a *Agent) DeleteReleaseRevision(
+	name string,
+	version int,
+) error {
+	_, err := a.ActionConfig.Releases.Delete(name, version)
+
+	return err
+}
+
 // GetReleaseHistory returns a list of charts for a specific release
 func (a *Agent) GetReleaseHistory(
 	name string,

+ 9 - 1
internal/kubernetes/config.go

@@ -341,7 +341,15 @@ func (conf *OutOfClusterConfig) CreateRawConfigFromCluster() (*api.Config, error
 			return nil, err
 		}
 
-		tok, err := awsAuth.GetBearerToken(conf.getTokenCache, conf.setTokenCache, cluster.Name)
+		awsClusterID := cluster.Name
+		shouldOverride := false
+
+		if cluster.AWSClusterID != "" {
+			awsClusterID = cluster.AWSClusterID
+			shouldOverride = true
+		}
+
+		tok, err := awsAuth.GetBearerToken(conf.getTokenCache, conf.setTokenCache, awsClusterID, shouldOverride)
 
 		if err != nil {
 			return nil, err

+ 6 - 0
internal/models/cluster.go

@@ -55,6 +55,8 @@ type Cluster struct {
 
 	NotificationsDisabled bool `json:"notifications_disabled"`
 
+	AWSClusterID string
+
 	// ------------------------------------------------------------------
 	// All fields below this line are encrypted before storage
 	// ------------------------------------------------------------------
@@ -73,6 +75,9 @@ type Cluster struct {
 
 	// CertificateAuthorityData for the cluster, encrypted at rest
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
+
+	// MonitorHelmReleases to trim down the number of revisions per release
+	MonitorHelmReleases bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -97,6 +102,7 @@ func (c *Cluster) ToClusterType() *types.Cluster {
 		Service:          serv,
 		InfraID:          c.InfraID,
 		AWSIntegrationID: c.AWSIntegrationID,
+		AWSClusterID:     c.AWSClusterID,
 	}
 }
 

+ 10 - 2
internal/models/infra.go

@@ -128,6 +128,8 @@ func GetOperationID() (string, error) {
 type getInfraName struct {
 	Name        string `json:"name"`
 	ClusterName string `json:"cluster_name"`
+	DBName      string `json:"db_name"`
+	BucketName  string `json:"bucket_name"`
 	DOCRName    string `json:"docr_name"`
 	ECRName     string `json:"ecr_name"`
 	ACRName     string `json:"acr_name"`
@@ -152,6 +154,14 @@ func (i *Infra) ToInfraType() *types.Infra {
 			name = infraName.ACRName
 		}
 
+		if infraName.DBName != "" {
+			name = infraName.DBName
+		}
+
+		if infraName.BucketName != "" {
+			name = infraName.BucketName
+		}
+
 		if infraName.ClusterName != "" {
 			name = infraName.ClusterName
 		}
@@ -159,8 +169,6 @@ func (i *Infra) ToInfraType() *types.Infra {
 		if infraName.Name != "" {
 			name = infraName.Name
 		}
-	} else if err != nil {
-		fmt.Println("ERRWAS", err)
 	}
 
 	return &types.Infra{

+ 11 - 4
internal/models/integrations/aws.go

@@ -105,6 +105,7 @@ func (a *AWSIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
 	clusterID string,
+	shouldClusterIdOverride bool,
 ) (string, error) {
 	cache, err := getTokenCache()
 
@@ -127,15 +128,21 @@ func (a *AWSIntegration) GetBearerToken(
 		return "", err
 	}
 
-	clusterIDGuess := string(a.AWSClusterID)
+	var validClusterId string
 
-	if clusterIDGuess == "" {
-		clusterIDGuess = clusterID
+	if shouldClusterIdOverride {
+		validClusterId = clusterID
+	} else {
+		validClusterId = string(a.AWSClusterID)
+
+		if validClusterId == "" {
+			validClusterId = clusterID
+		}
 	}
 
 	tok, err := generator.GetWithOptions(&token.GetTokenOptions{
 		Session:   sess,
-		ClusterID: clusterIDGuess,
+		ClusterID: validClusterId,
 	})
 
 	if err != nil {

+ 9 - 0
internal/repository/gorm/project.go

@@ -41,6 +41,15 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+// UpdateProject updates an existing project
+func (repo *ProjectRepository) UpdateProject(project *models.Project) (*models.Project, error) {
+	if err := repo.db.Save(project).Error; err != nil {
+		return nil, err
+	}
+
+	return project, nil
+}
+
 func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
 	foundRole := &models.Role{}
 

+ 1 - 0
internal/repository/project.go

@@ -11,6 +11,7 @@ type WriteProject func(project *models.Project) (*models.Project, error)
 type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
+	UpdateProject(project *models.Project) (*models.Project, error)
 	UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error)
 	ReadProject(id uint) (*models.Project, error)
 	ReadProjectRole(projID, userID uint) (*models.Role, error)

+ 4 - 0
internal/repository/test/project.go

@@ -60,6 +60,10 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+func (repo *ProjectRepository) UpdateProject(project *models.Project) (*models.Project, error) {
+	panic("unimplemented")
+}
+
 // CreateProjectRole appends a role to the existing array of roles
 func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
 	if !repo.canQuery {

+ 76 - 0
internal/worker/dispatcher.go

@@ -0,0 +1,76 @@
+package worker
+
+import (
+	"log"
+
+	"github.com/google/uuid"
+)
+
+// Dispatcher is responsible to maintain a global worker pool
+// and to dispatch jobs to the underlying workers, in random order
+type Dispatcher struct {
+	maxWorkers int
+	exitChan   chan bool
+
+	WorkerPool chan chan Job
+}
+
+// NewDispatcher creates a new instance of Dispatcher with
+// the given number of workers that should be in the worker pool
+func NewDispatcher(maxWorkers int) *Dispatcher {
+	pool := make(chan chan Job, maxWorkers)
+	return &Dispatcher{
+		maxWorkers: maxWorkers,
+		exitChan:   make(chan bool),
+
+		WorkerPool: pool,
+	}
+}
+
+// Run creates workers in the worker pool with the given
+// job queue and starts the workers
+func (d *Dispatcher) Run(jobQueue chan Job) error {
+	go func() {
+		var workers []*Worker
+
+		for i := 0; i < d.maxWorkers; i += 1 {
+			uuid, err := uuid.NewUUID()
+
+			if err != nil {
+				// FIXME: should let the parent thread know of this error
+				log.Printf("error creating UUID for worker: %v", err)
+				return
+			}
+
+			worker := NewWorker(uuid, d.WorkerPool)
+			workers = append(workers, worker)
+
+			log.Printf("starting worker with UUID: %v", uuid)
+
+			worker.Start()
+		}
+
+		for {
+			select {
+			case job := <-jobQueue:
+				go func() {
+					workerJobChan := <-d.WorkerPool
+					workerJobChan <- job
+				}()
+			case <-d.exitChan:
+				for _, w := range workers {
+					w.Stop()
+				}
+
+				return
+			}
+		}
+	}()
+
+	return nil
+}
+
+// Exit instructs the dispatcher to quit processing any more jobs
+func (d *Dispatcher) Exit() {
+	d.exitChan <- true
+}

+ 22 - 0
internal/worker/dispatcher_test.go

@@ -0,0 +1,22 @@
+package worker
+
+import (
+	"testing"
+
+	"go.uber.org/goleak"
+)
+
+func TestDispatcher(t *testing.T) {
+	defer goleak.VerifyNone(t)
+
+	jobChan := make(chan Job)
+
+	d := NewDispatcher(10)
+	err := d.Run(jobChan)
+
+	if err != nil {
+		panic(err)
+	}
+
+	d.Exit()
+}

+ 73 - 0
internal/worker/worker.go

@@ -0,0 +1,73 @@
+package worker
+
+import (
+	"log"
+	"time"
+
+	"github.com/google/uuid"
+)
+
+// Job is an interface which should be implemented by an individual
+// worker process in order to be enqueued in the worker pool
+type Job interface {
+	// The unique string ID of a job
+	ID() string
+
+	// The time in UTC when a job was enqueued to the worker pool queue
+	EnqueueTime() time.Time
+
+	// The main logic and control of a job
+	Run() error
+
+	// To set external data if a job needs it
+	SetData([]byte)
+}
+
+// Worker handles a single job or worker process
+type Worker struct {
+	exitChan chan bool
+	uuid     uuid.UUID
+
+	WorkerPool chan chan Job
+	JobChannel chan Job
+}
+
+// NewWorker creates a new instance of Worker with the given
+// RFC 4122 UUID and a global worker pool
+func NewWorker(uuid uuid.UUID, workerPool chan chan Job) *Worker {
+	return &Worker{
+		exitChan: make(chan bool),
+		uuid:     uuid,
+
+		WorkerPool: workerPool,
+		JobChannel: make(chan Job),
+	}
+}
+
+// Start spawns a goroutine to add itself to the global worker pool
+// and listens for incoming jobs as they come, in random order
+func (w *Worker) Start() {
+	go func() {
+		for {
+			w.WorkerPool <- w.JobChannel
+
+			select {
+			case job := <-w.JobChannel:
+				log.Printf("attempting to run job ID '%s' via worker '%s'", job.ID(), w.uuid.String())
+
+				if err := job.Run(); err != nil {
+					log.Printf("error running job %s: %s", job.ID(), err.Error())
+				}
+			case <-w.exitChan:
+				log.Printf("quitting worker with UUID: %v", w.uuid)
+
+				return
+			}
+		}
+	}()
+}
+
+// Stop instructs the worker to stop listening for incoming jobs
+func (w *Worker) Stop() {
+	w.exitChan <- true
+}

+ 25 - 0
internal/worker/worker_test.go

@@ -0,0 +1,25 @@
+package worker
+
+import (
+	"testing"
+
+	"github.com/google/uuid"
+	"go.uber.org/goleak"
+)
+
+func TestWorker(t *testing.T) {
+	defer goleak.VerifyNone(t)
+
+	uuid, err := uuid.NewUUID()
+
+	if err != nil {
+		panic(err)
+	}
+
+	workerPool := make(chan chan Job, 10)
+
+	w := NewWorker(uuid, workerPool)
+
+	w.Start()
+	w.Stop()
+}

+ 20 - 0
provisioner/integrations/storage/s3/s3.go

@@ -78,6 +78,26 @@ func (s *S3StorageClient) WriteFile(infra *models.Infra, name string, fileBytes
 	return err
 }
 
+func (s *S3StorageClient) WriteFileWithKey(fileBytes []byte, shouldEncrypt bool, key string) error {
+	body := fileBytes
+	var err error
+	if shouldEncrypt {
+		body, err = encryption.Encrypt(fileBytes, s.encryptionKey)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	_, err = s.client.PutObject(&s3.PutObjectInput{
+		Body:   aws.ReadSeekCloser(bytes.NewReader(body)),
+		Bucket: &s.bucket,
+		Key:    aws.String(key),
+	})
+
+	return err
+}
+
 func (s *S3StorageClient) ReadFile(infra *models.Infra, name string, shouldDecrypt bool) ([]byte, error) {
 	output, err := s.client.GetObject(&s3.GetObjectInput{
 		Bucket: &s.bucket,

+ 30 - 0
workers/Dockerfile

@@ -0,0 +1,30 @@
+# This Dockerfile is used for building the worker pool binary itself
+
+# Buildtime environment
+# -------------------------------------------
+FROM golang:1.18-alpine3.16 as build
+WORKDIR /app
+
+RUN apk update && apk add gcc binutils-gold musl-dev
+
+COPY go.mod .
+COPY go.sum .
+COPY /api ./api
+COPY /ee ./ee
+COPY /internal ./internal
+COPY /pkg ./pkg
+COPY /provisioner ./provisioner
+COPY /workers ./workers
+
+RUN go build -ldflags '-w -s' -tags ee -a -o ./bin/worker-pool ./workers
+
+# Runtime environment
+# ----------------------
+FROM alpine:3.16
+WORKDIR /app
+
+RUN apk update && apk add curl
+
+COPY --from=build /app/bin/worker-pool /usr/bin/
+
+ENTRYPOINT [ "worker-pool" ]

+ 28 - 0
workers/doc.go

@@ -0,0 +1,28 @@
+/*
+
+                            === Porter Worker Pool and Job Queue System ===
+
+This software is intended to be deployed alongside the main Porter server and dashboard and act as a background
+worker pool for certain jobs that the Porter server should be running as separate processes / goroutines periodically
+or at-will, depending on the task at hand.
+
+TERMINOLOGIES
+
+  - The terms `worker pool`, `pool`, `Go application` are interchangably used to denote this application.
+  - Jobs should have their unique string identifiers, denoted as IDs for short.
+
+ARCHITECTURE
+
+  - The worker pool is a Go application that takes in environment variables `MAX_WORKERS` and `MAX_QUEUE` to
+    denote the maximum number of workers and maximum number of jobs in the queue, respectively.
+  - The worker pool has specific jobs that it can execute, written separately with their own logic flow.
+  - The individual jobs need to have a unique string identifier.
+  - The jobs should be registered at startup time with their respective unique identifiers for the worker pool
+    to correctly relay execution information to the correct job.
+  - The worker pool has an exposed HTTP POST endpoint to enqueue jobs with their IDs. Depending on the kind of job,
+    a job can expect to receive a body of JSON data in the HTTP request.
+  - By exposing an HTTP endpoint, the worker pool can be called to enqueue jobs using crontab and other sources.
+
+*/
+
+package main

+ 302 - 0
workers/jobs/helm_revisions_count_tracker.go

@@ -0,0 +1,302 @@
+//go:build ee
+
+/*
+
+                            === Helm Release Revisions Tracker Job ===
+
+This job keeps a track of helm releases and their revisions and deletes older revisions once they are
+backed up to an S3 bucket.
+
+  - The job looks for clusters which have the `monitor_helm_releases` set to true.
+  - The clusters are then checked for old helm release revisions.
+  - In a cluster, list of all namespaces is fetched.
+  - For every namespace, the list of releases is fetched.
+  - For every release, its revision history is fetched.
+  - If the number of revisions exceeds 100, then we intend to only keep the most recent 100 revisions.
+  - For this, the older revisions are first backed up to an S3 bucket and then deleted.
+
+*/
+
+package jobs
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"sync"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/pkg/logger"
+	"github.com/porter-dev/porter/provisioner/integrations/storage/s3"
+
+	"github.com/porter-dev/porter/ee/integrations/vault"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	rcreds "github.com/porter-dev/porter/internal/repository/credentials"
+	rgorm "github.com/porter-dev/porter/internal/repository/gorm"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+	"helm.sh/helm/v3/pkg/releaseutil"
+)
+
+var stepSize int = 100
+
+type helmRevisionsCountTracker struct {
+	enqueueTime        time.Time
+	db                 *gorm.DB
+	repo               repository.Repository
+	doConf             *oauth2.Config
+	dbConf             *env.DBConf
+	credBackend        rcreds.CredentialStorage
+	awsAccessKeyID     string
+	awsSecretAccessKey string
+	awsRegion          string
+	s3BucketName       string
+	encryptionKey      *[32]byte
+}
+
+// HelmRevisionsCountTrackerOpts holds the options required to run this job
+type HelmRevisionsCountTrackerOpts struct {
+	DBConf             *env.DBConf
+	DOClientID         string
+	DOClientSecret     string
+	DOScopes           []string
+	ServerURL          string
+	AWSAccessKeyID     string
+	AWSSecretAccessKey string
+	AWSRegion          string
+	S3BucketName       string
+	EncryptionKey      string
+}
+
+func NewHelmRevisionsCountTracker(
+	enqueueTime time.Time,
+	opts *HelmRevisionsCountTrackerOpts,
+) (*helmRevisionsCountTracker, error) {
+	db, err := adapter.New(opts.DBConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var credBackend rcreds.CredentialStorage
+
+	if opts.DBConf.VaultAPIKey != "" && opts.DBConf.VaultServerURL != "" && opts.DBConf.VaultPrefix != "" {
+		credBackend = vault.NewClient(
+			opts.DBConf.VaultServerURL,
+			opts.DBConf.VaultAPIKey,
+			opts.DBConf.VaultPrefix,
+		)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte(opts.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := rgorm.NewRepository(db, &key, credBackend)
+
+	doConf := oauth.NewDigitalOceanClient(&oauth.Config{
+		ClientID:     opts.DOClientID,
+		ClientSecret: opts.DOClientSecret,
+		Scopes:       opts.DOScopes,
+		BaseURL:      opts.ServerURL,
+	})
+
+	var s3Key [32]byte
+
+	for i, b := range []byte(opts.EncryptionKey) {
+		s3Key[i] = b
+	}
+
+	return &helmRevisionsCountTracker{
+		enqueueTime, db, repo, doConf, opts.DBConf, credBackend,
+		opts.AWSAccessKeyID, opts.AWSSecretAccessKey, opts.AWSRegion,
+		opts.S3BucketName, &s3Key,
+	}, nil
+}
+
+func (t *helmRevisionsCountTracker) ID() string {
+	return "helm-revisions-count-tracker"
+}
+
+func (t *helmRevisionsCountTracker) EnqueueTime() time.Time {
+	return t.enqueueTime
+}
+
+func (t *helmRevisionsCountTracker) Run() error {
+	var count int64
+
+	if err := t.db.Model(&models.Cluster{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		var clusters []*models.Cluster
+
+		if err := t.db.Order("id asc").Offset(i*stepSize).Limit(stepSize).Find(&clusters, "monitor_helm_releases = ?", "1").
+			Error; err != nil {
+			return err
+		}
+
+		// go through each project
+		for _, cluster := range clusters {
+			wg.Add(1)
+
+			go func(projID, clusterID uint) {
+				defer wg.Done()
+
+				log.Printf("starting release revision monitoring for cluster with ID %d", cluster.ID)
+
+				cluster, err := t.repo.Cluster().ReadCluster(projID, clusterID)
+
+				if err != nil {
+					log.Printf("error reading cluster ID %d: %v. skipping cluster ...", clusterID, err)
+					return
+				}
+
+				// create s3 client to store revisions that need to be deleted
+				s3Client, err := s3.NewS3StorageClient(&s3.S3Options{
+					t.awsRegion, t.awsAccessKeyID, t.awsSecretAccessKey, t.s3BucketName, t.encryptionKey,
+				})
+
+				if err != nil {
+					log.Printf("error creating S3 client for cluster ID %d: %v. skipping cluster ...", cluster.ID, err)
+					return
+				}
+
+				k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(&kubernetes.OutOfClusterConfig{
+					Cluster:                   cluster,
+					Repo:                      t.repo,
+					DigitalOceanOAuth:         t.doConf,
+					AllowInClusterConnections: false,
+				})
+
+				if err != nil {
+					log.Printf("error getting k8s agent for cluster ID %d: %v. skipping cluster ...", cluster.ID, err)
+					return
+				}
+
+				namespaces, err := k8sAgent.ListNamespaces()
+
+				if err != nil {
+					log.Printf("error fetching namespaces for cluster ID %d: %v. skipping cluster ...", cluster.ID, err)
+					return
+				}
+
+				log.Printf("fetched %d namespaces for cluster ID %d", len(namespaces.Items), cluster.ID)
+
+				for _, ns := range namespaces.Items {
+					agent, err := helm.GetAgentOutOfClusterConfig(&helm.Form{
+						Cluster:                   cluster,
+						Namespace:                 ns.Name,
+						Repo:                      t.repo,
+						DigitalOceanOAuth:         t.doConf,
+						AllowInClusterConnections: false,
+					}, logger.New(true, os.Stdout))
+
+					if err != nil {
+						log.Printf("error fetching helm client for namespace %s in cluster ID %d: %v. "+
+							"skipping namespace ...", ns.Name, cluster.ID, err)
+						continue
+					}
+
+					releases, err := agent.ListReleases(ns.GetName(), &types.ReleaseListFilter{
+						ByDate: true,
+						StatusFilter: []string{
+							"deployed",
+							"pending",
+							"pending-install",
+							"pending-upgrade",
+							"pending-rollback",
+							"failed",
+						},
+					})
+
+					if err != nil {
+						log.Printf("error fetching releases for namespace %s in cluster ID %d: %v. skipping namespace ...",
+							len(releases), ns.Name, cluster.ID, err)
+						continue
+					}
+
+					log.Printf("fetched %d releases for namespace %s in cluster ID %d", len(releases), ns.Name, cluster.ID)
+
+					for _, rel := range releases {
+						revisions, err := agent.GetReleaseHistory(rel.Name)
+
+						if err != nil {
+							log.Printf("error fetching release history for release %s in namespace %s of cluster ID %d: %v."+
+								" skipping release ...", rel.Name, ns.Name, cluster.ID, err)
+							continue
+						}
+
+						if len(revisions) <= 100 {
+							log.Printf("release %s of namespace %s in cluster ID %d has <= 100 revisions. "+
+								"skipping release...", rel.Name, ns.Name, cluster.ID)
+							continue
+						}
+
+						log.Printf("release %s of namespace %s in cluster ID %d has more than 100 revisions. attempting to "+
+							"delete the older ones.", rel.Name, ns.Name, cluster.ID)
+
+						// sort revisions from newest to oldest
+						releaseutil.Reverse(revisions, releaseutil.SortByRevision)
+
+						for i := 100; i < len(revisions); i += 1 {
+							rev := revisions[i]
+
+							// store the revision in the s3 bucket before deleting it
+							data, err := json.Marshal(rev)
+
+							if err != nil {
+								log.Printf("error marshalling revision for release %s, number %d: %v. skipping revision ...",
+									rev.Name, rev.Version, err)
+								continue
+							}
+
+							// write to the bucket with key - <project_id>/<cluster_id>/<namespace>/<release_name>/<revision_number>
+							err = s3Client.WriteFileWithKey(data, true, fmt.Sprintf("%d/%d/%s/%s/%d", cluster.ProjectID,
+								cluster.ID, rel.Namespace, rel.Name, rev.Version))
+
+							if err != nil {
+								log.Printf("error backing up revision for release %s, number %d: %v. skipping revision ...",
+									rev.Name, rev.Version, err)
+								continue
+							}
+
+							log.Printf("revision %d of release %s in namespace %s of cluster ID %d was successfully backed up.",
+								rev.Version, rel.Name, ns.Name, cluster.ID)
+
+							err = agent.DeleteReleaseRevision(rev.Name, rev.Version)
+
+							if err != nil {
+								log.Printf("error deleting revision %d of release %s in namespace %s of cluster ID %d: %v",
+									rev.Version, rel.Name, ns.Name, cluster.ID, err)
+								continue
+							}
+
+							log.Printf("revision %d of release %s in namespace %s of cluster ID %d was successfully deleted.",
+								rev.Version, rel.Name, ns.Name, cluster.ID)
+						}
+					}
+				}
+			}(cluster.ProjectID, cluster.ID)
+		}
+
+		wg.Wait()
+	}
+
+	return nil
+}
+
+func (t *helmRevisionsCountTracker) SetData([]byte) {}

+ 159 - 0
workers/main.go

@@ -0,0 +1,159 @@
+//go:build ee
+
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/middleware"
+	"github.com/joeshaw/envdecode"
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/internal/worker"
+	"github.com/porter-dev/porter/workers/jobs"
+)
+
+var (
+	jobQueue   chan worker.Job
+	envDecoder = EnvConf{}
+)
+
+// EnvConf holds the environment variables for this binary
+type EnvConf struct {
+	ServerURL          string `env:"SERVER_URL,default=http://localhost:8080"`
+	DOClientID         string `env:"DO_CLIENT_ID"`
+	DOClientSecret     string `env:"DO_CLIENT_SECRET"`
+	DBConf             env.DBConf
+	MaxWorkers         uint   `env:"MAX_WORKERS,default=10"`
+	MaxQueue           uint   `env:"MAX_QUEUE,default=100"`
+	AWSAccessKeyID     string `env:"AWS_ACCESS_KEY_ID"`
+	AWSSecretAccessKey string `env:"AWS_SECRET_ACCESS_KEY"`
+	AWSRegion          string `env:"AWS_REGION"`
+	S3BucketName       string `env:"S3_BUCKET_NAME"`
+	EncryptionKey      string `env:"S3_ENCRYPTION_KEY"`
+
+	Port uint `env:"PORT,default=3000"`
+}
+
+func main() {
+	if err := envdecode.StrictDecode(&envDecoder); err != nil {
+		log.Fatalf("Failed to decode server conf: %v", err)
+	}
+
+	log.Printf("setting max worker count to: %d\n", envDecoder.MaxWorkers)
+	log.Printf("setting max job queue count to: %d\n", envDecoder.MaxQueue)
+
+	jobQueue = make(chan worker.Job, envDecoder.MaxQueue)
+	d := worker.NewDispatcher(int(envDecoder.MaxWorkers))
+
+	log.Println("starting worker dispatcher")
+
+	err := d.Run(jobQueue)
+
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	server := &http.Server{Addr: fmt.Sprintf(":%d", envDecoder.Port), Handler: httpService()}
+
+	serverCtx, serverStopCtx := context.WithCancel(context.Background())
+
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
+	go func() {
+		<-sig
+
+		log.Println("shutting down server")
+
+		shutdownCtx, shutdownCtxCancel := context.WithTimeout(serverCtx, 30*time.Second)
+		defer shutdownCtxCancel()
+
+		go func() {
+			<-shutdownCtx.Done()
+			if shutdownCtx.Err() == context.DeadlineExceeded {
+				log.Fatal("graceful shutdown timed out.. forcing exit.")
+			}
+		}()
+
+		err = server.Shutdown(shutdownCtx)
+
+		if err != nil {
+			log.Fatalln(err)
+		}
+
+		log.Println("server shutdown completed")
+
+		serverStopCtx()
+	}()
+
+	log.Println("starting HTTP server at :3000")
+
+	err = server.ListenAndServe()
+	if err != nil && err != http.ErrServerClosed {
+		log.Fatalf("error starting HTTP server: %v", err)
+	}
+
+	// Wait for server context to be stopped
+	<-serverCtx.Done()
+
+	d.Exit()
+}
+
+func httpService() http.Handler {
+	log.Println("setting up HTTP router and adding middleware")
+
+	r := chi.NewRouter()
+	r.Use(middleware.Logger)
+	r.Use(middleware.Recoverer)
+	r.Use(middleware.Heartbeat("/ping"))
+	r.Use(middleware.AllowContentType("application/json"))
+
+	log.Println("setting up HTTP POST endpoint to enqueue jobs")
+
+	r.Post("/enqueue/{id}", func(w http.ResponseWriter, r *http.Request) {
+		job := getJob(chi.URLParam(r, "id"))
+
+		if job == nil {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		jobQueue <- job
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	return r
+}
+
+func getJob(id string) worker.Job {
+	if id == "helm-revisions-count-tracker" {
+		newJob, err := jobs.NewHelmRevisionsCountTracker(time.Now().UTC(), &jobs.HelmRevisionsCountTrackerOpts{
+			DBConf:             &envDecoder.DBConf,
+			DOClientID:         envDecoder.DOClientID,
+			DOClientSecret:     envDecoder.DOClientSecret,
+			DOScopes:           []string{"read", "write"},
+			ServerURL:          envDecoder.ServerURL,
+			AWSAccessKeyID:     envDecoder.AWSAccessKeyID,
+			AWSSecretAccessKey: envDecoder.AWSSecretAccessKey,
+			AWSRegion:          envDecoder.AWSRegion,
+			S3BucketName:       envDecoder.S3BucketName,
+			EncryptionKey:      envDecoder.EncryptionKey,
+		})
+
+		if err != nil {
+			log.Printf("error creating job with ID: helm-revisions-count-tracker. Error: %v", err)
+			return nil
+		}
+
+		return newJob
+	}
+
+	return nil
+}