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

Merge pull request #1329 from porter-dev/belanger/por-83-usage-enforcement

[POR-93] Usage enforcement and pricing integration
abelanger5 4 лет назад
Родитель
Сommit
63afb8aebd
100 измененных файлов с 3419 добавлено и 669 удалено
  1. 2 1
      .air.toml
  2. 1 1
      .github/workflows/dev.yaml
  3. 1 1
      .github/workflows/production.yaml
  4. 1 1
      .github/workflows/staging.yaml
  5. 7 3
      LICENSE
  6. 5 5
      api/server/authz/policy_test.go
  7. 2 2
      api/server/authz/project_test.go
  8. 36 0
      api/server/handlers/billing/billing_ce.go
  9. 28 0
      api/server/handlers/billing/billing_ee.go
  10. 18 0
      api/server/handlers/handler.go
  11. 68 0
      api/server/handlers/invite/invite_ce.go
  12. 44 0
      api/server/handlers/invite/invite_ee.go
  13. 42 9
      api/server/handlers/project/create.go
  14. 7 0
      api/server/handlers/project/delete.go
  15. 8 0
      api/server/handlers/project/delete_role.go
  16. 41 0
      api/server/handlers/project/get_billing.go
  17. 1 1
      api/server/handlers/project/get_test.go
  18. 51 0
      api/server/handlers/project/get_usage.go
  19. 2 2
      api/server/handlers/project/list_test.go
  20. 8 0
      api/server/handlers/project/update_role.go
  21. 2 0
      api/server/router/cluster.go
  22. 6 0
      api/server/router/invite.go
  23. 101 0
      api/server/router/middleware/usage.go
  24. 114 0
      api/server/router/project.go
  25. 6 0
      api/server/router/router.go
  26. 9 0
      api/server/shared/apierrors/errors.go
  27. 4 2
      api/server/shared/apitest/config.go
  28. 8 1
      api/server/shared/config/config.go
  29. 4 0
      api/server/shared/config/env/envconfs.go
  30. 1 1
      api/server/shared/config/envloader/envloader.go
  31. 7 0
      api/server/shared/config/loader/init_ce.go
  32. 41 0
      api/server/shared/config/loader/init_ee.go
  33. 42 21
      api/server/shared/config/loader/loader.go
  34. 11 9
      api/server/shared/config/metadata.go
  35. 7 0
      api/types/error.go
  36. 8 0
      api/types/project.go
  37. 6 0
      api/types/request.go
  38. 66 0
      api/types/usage.go
  39. 2 2
      cmd/app/main.go
  40. 2 2
      cmd/migrate/main.go
  41. 2 2
      cmd/ready/main.go
  42. 66 0
      dashboard/package-lock.json
  43. 1 0
      dashboard/package.json
  44. 2 2
      dashboard/src/components/repo-selector/ContentsList.tsx
  45. 1 0
      dashboard/src/main/Main.tsx
  46. 49 1
      dashboard/src/main/home/Home.tsx
  47. 10 2
      dashboard/src/main/home/dashboard/Dashboard.tsx
  48. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  49. 0 72
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  50. 3 73
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  51. 3 61
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  52. 5 52
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  53. 3 109
      dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx
  54. 7 63
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  55. 44 3
      dashboard/src/main/home/modals/Modal.tsx
  56. 3 61
      dashboard/src/main/home/modals/NamespaceModal.tsx
  57. 3 61
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  58. 189 0
      dashboard/src/main/home/modals/UsageWarningModal.tsx
  59. 66 0
      dashboard/src/main/home/project-settings/BillingPage.tsx
  60. 70 30
      dashboard/src/main/home/project-settings/InviteList.tsx
  61. 55 2
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  62. 41 1
      dashboard/src/shared/Context.tsx
  63. 19 0
      dashboard/src/shared/api.tsx
  64. 21 0
      dashboard/src/shared/types.tsx
  65. 1 1
      docs/guides/using-env-groups.md
  66. 36 0
      ee/LICENSE
  67. 65 0
      ee/api/server/handlers/billing/get_token.go
  68. 84 0
      ee/api/server/handlers/billing/webhook.go
  69. 26 3
      ee/api/server/handlers/invite/accept.go
  70. 3 1
      ee/api/server/handlers/invite/create.go
  71. 3 1
      ee/api/server/handlers/invite/delete.go
  72. 3 1
      ee/api/server/handlers/invite/list.go
  73. 3 1
      ee/api/server/handlers/invite/update_role.go
  74. 477 0
      ee/billing/ironplans.go
  75. 98 0
      ee/billing/types.go
  76. 75 0
      ee/docker/ee.Dockerfile
  77. 13 0
      ee/models/project_billing.go
  78. 15 0
      ee/models/user_billing.go
  79. 46 0
      ee/repository/gorm/project_billing.go
  80. 29 0
      ee/repository/gorm/repository.go
  81. 116 0
      ee/repository/gorm/user_billing.go
  82. 11 0
      ee/repository/project_billing.go
  83. 8 0
      ee/repository/repository.go
  84. 11 0
      ee/repository/user_billing.go
  85. 29 0
      ee/usage/limit.go
  86. 82 0
      internal/billing/billing.go
  87. 28 0
      internal/kubernetes/nodes/nodes.go
  88. 12 0
      internal/models/project.go
  89. 64 0
      internal/models/usage.go
  90. 2 0
      internal/repository/gorm/migrate.go
  91. 6 0
      internal/repository/gorm/repository.go
  92. 87 0
      internal/repository/gorm/usage.go
  93. 2 1
      internal/repository/repository.go
  94. 6 0
      internal/repository/test/repository.go
  95. 129 0
      internal/repository/test/usage.go
  96. 13 0
      internal/repository/usage.go
  97. 15 0
      internal/usage/limit_ce.go
  98. 16 0
      internal/usage/limit_ee.go
  99. 150 0
      internal/usage/usage.go
  100. 171 0
      services/usage/usage.go

+ 2 - 1
.air.toml

@@ -7,7 +7,8 @@ tmp_dir = "tmp"
 
 [build]
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/app -tags ee -ldflags=\"-X 'main.Version=dev-ee'\" ./cmd/app"
+
 # Binary file yields from `cmd`.
 bin = "tmp/app"
 # Customize binary.

+ 1 - 1
.github/workflows/dev.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./ee/docker/ee.Dockerfile
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:dev

+ 1 - 1
.github/workflows/production.yaml

@@ -43,7 +43,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile --build-arg version=production
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./ee/docker/ee.Dockerfile --build-arg version=production
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:latest

+ 1 - 1
.github/workflows/staging.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./ee/docker/ee.Dockerfile
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:staging

+ 7 - 3
LICENSE

@@ -1,6 +1,10 @@
-MIT License
+Copyright (c) 2021-present Porter Technologies Inc.
 
-Copyright (c) 2020 Porter Technologies Inc.
+Portions of this software are licensed as follows:
+
+* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE".
+* All third party components incorporated into the Porter Software are licensed under the original license provided by the owner of the applicable component.
+* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +22,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+SOFTWARE.

+ 5 - 5
api/server/authz/policy_test.go

@@ -28,7 +28,7 @@ func TestPolicyMiddlewareSuccessfulProjectCluster(t *testing.T) {
 	}, false, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -76,7 +76,7 @@ func TestPolicyMiddlewareSuccessfulApplication(t *testing.T) {
 	}, false, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -141,7 +141,7 @@ func TestPolicyMiddlewareInvalidPermissions(t *testing.T) {
 	}, false, true)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -175,7 +175,7 @@ func TestPolicyMiddlewareFailInvalidLoader(t *testing.T) {
 	}, true, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -208,7 +208,7 @@ func TestPolicyMiddlewareFailBadParam(t *testing.T) {
 	}, true, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 2 - 2
api/server/authz/project_test.go

@@ -18,7 +18,7 @@ func TestProjectMiddlewareSuccessful(t *testing.T) {
 	config, handler, next := loadProjectHandlers(t)
 
 	user := apitest.CreateTestUser(t, config, true)
-	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -46,7 +46,7 @@ func TestProjectMiddlewareFailedRead(t *testing.T) {
 	config, _, _ := loadProjectHandlers(t)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 36 - 0
api/server/handlers/billing/billing_ce.go

@@ -0,0 +1,36 @@
+// +build !ee
+
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"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
+}
+
+func NewBillingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return handlers.NewUnavailable(config, "billing_webhook")
+}

+ 28 - 0
api/server/handlers/billing/billing_ee.go

@@ -0,0 +1,28 @@
+// +build ee
+
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+
+	"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
+
+func init() {
+	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
+	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
+}

+ 18 - 0
api/server/handlers/handler.go

@@ -108,3 +108,21 @@ func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *ht
 
 	return nil
 }
+
+type Unavailable struct {
+	config    *config.Config
+	handlerID string
+}
+
+func NewUnavailable(config *config.Config, handlerID string) *Unavailable {
+	return &Unavailable{config, handlerID}
+}
+
+func (u *Unavailable) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	apierrors.HandleAPIError(u.config, w, r, apierrors.NewErrPassThroughToClient(
+		fmt.Errorf("%s not available in community edition", u.handlerID),
+		http.StatusBadRequest,
+	), true, apierrors.ErrorOpts{
+		Code: types.ErrCodeUnavailable,
+	})
+}

+ 68 - 0
api/server/handlers/invite/invite_ce.go

@@ -0,0 +1,68 @@
+// +build !ee
+
+package invite
+
+import (
+	"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/config"
+)
+
+type InviteUpdateRoleHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewInviteUpdateRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_update_role")
+}
+
+type InviteAcceptHandler struct {
+	handlers.PorterHandler
+}
+
+func NewInviteAcceptHandler(
+	config *config.Config,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_accept")
+}
+
+type InviteCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInviteCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_create")
+}
+
+type InviteDeleteHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewInviteDeleteHandler(
+	config *config.Config,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_delete")
+}
+
+type InvitesListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInvitesListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_list")
+}

+ 44 - 0
api/server/handlers/invite/invite_ee.go

@@ -0,0 +1,44 @@
+// +build ee
+
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+
+	"github.com/porter-dev/porter/ee/api/server/handlers/invite"
+)
+
+var NewInviteUpdateRoleHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler
+
+var NewInviteAcceptHandler func(
+	config *config.Config,
+) http.Handler
+
+var NewInviteCreateHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler
+
+var NewInviteDeleteHandler func(
+	config *config.Config,
+) http.Handler
+
+var NewInvitesListHandler func(
+	config *config.Config,
+	writer shared.ResultWriter,
+) http.Handler
+
+func init() {
+	NewInviteUpdateRoleHandler = invite.NewInviteUpdateRoleHandler
+	NewInviteAcceptHandler = invite.NewInviteAcceptHandler
+	NewInviteCreateHandler = invite.NewInviteCreateHandler
+	NewInviteDeleteHandler = invite.NewInviteDeleteHandler
+	NewInvitesListHandler = invite.NewInvitesListHandler
+}

+ 42 - 9
api/server/handlers/project/create.go

@@ -44,33 +44,66 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	var err error
-	proj, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
+	proj, role, err := CreateProjectWithUser(p.Repo().Project(), proj, user)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	// create default project usage restriction
+	_, err = p.Repo().ProjectUsage().CreateProjectUsage(&models.ProjectUsage{
+		ProjectID:      proj.ID,
+		ResourceCPU:    types.BasicPlan.ResourceCPU,
+		ResourceMemory: types.BasicPlan.ResourceMemory,
+		Clusters:       types.BasicPlan.Clusters,
+		Users:          types.BasicPlan.Users,
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, proj.ToProjectType())
+
+	// add project to billing team
+	teamID, err := p.Config().BillingManager.CreateTeam(proj)
+
+	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))
+	}
+
+	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),
 	}))
-
-	p.WriteResult(w, r, proj.ToProjectType())
 }
 
 func CreateProjectWithUser(
 	projectRepo repository.ProjectRepository,
 	proj *models.Project,
 	user *models.User,
-) (*models.Project, error) {
+) (*models.Project, *models.Role, error) {
 	proj, err := projectRepo.CreateProject(proj)
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// create a new Role with the user as the admin
-	_, err = projectRepo.CreateProjectRole(proj, &models.Role{
+	role, err := projectRepo.CreateProjectRole(proj, &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
@@ -79,15 +112,15 @@ func CreateProjectWithUser(
 	})
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// read the project again to get the model with the role attached
 	proj, err = projectRepo.ReadProject(proj.ID)
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
-	return proj, nil
+	return proj, role, nil
 }

+ 7 - 0
api/server/handlers/project/delete.go

@@ -35,4 +35,11 @@ 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 {
+		// 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))
+	}
 }

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

@@ -54,4 +54,12 @@ 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))
+	}
 }

+ 41 - 0
api/server/handlers/project/get_billing.go

@@ -0,0 +1,41 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ProjectGetBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewProjectGetBillingHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ProjectGetBillingHandler {
+	return &ProjectGetBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ProjectGetBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	res := &types.GetProjectBillingResponse{
+		HasBilling: false,
+	}
+
+	if sc := p.Config().ServerConf; sc.IronPlansAPIKey != "" && sc.IronPlansServerURL != "" {
+		// determine if the project has usage attached; if so, set has_billing to true
+		usage, _ := p.Repo().ProjectUsage().ReadProjectUsage(proj.ID)
+
+		res.HasBilling = usage != nil
+	}
+
+	p.WriteResult(w, r, res)
+}

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

@@ -14,7 +14,7 @@ func TestGetProjectSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
 	user := apitest.CreateTestUser(t, config, true)
-	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 51 - 0
api/server/handlers/project/get_usage.go

@@ -0,0 +1,51 @@
+package project
+
+import (
+	"net/http"
+
+	"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"
+	"github.com/porter-dev/porter/internal/usage"
+)
+
+type ProjectGetUsageHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewProjectGetUsageHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ProjectGetUsageHandler {
+	return &ProjectGetUsageHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ProjectGetUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	res := &types.GetProjectUsageResponse{}
+
+	currUsage, limit, usageCache, err := usage.GetUsage(&usage.GetUsageOpts{
+		Project:          proj,
+		DOConf:           p.Config().DOConf,
+		Repo:             p.Repo(),
+		WhitelistedUsers: p.Config().WhitelistedUsers,
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res.Current = *currUsage
+	res.Limit = *limit
+	res.IsExceeded = usageCache.Exceeded
+	res.ExceededSince = usageCache.ExceededSince
+
+	p.WriteResult(w, r, res)
+}

+ 2 - 2
api/server/handlers/project/list_test.go

@@ -15,7 +15,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
 	user := apitest.CreateTestUser(t, config, true)
-	proj1, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj1, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -23,7 +23,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	proj2, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj2, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project-2",
 	}, user)
 

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

@@ -57,4 +57,12 @@ 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))
+	}
 }

+ 2 - 0
api/server/router/cluster.go

@@ -153,6 +153,8 @@ func getClusterRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 

+ 6 - 0
api/server/router/invite.go

@@ -64,6 +64,7 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 			},
 		},
 	)
@@ -91,7 +92,10 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Users,
 		},
 	)
 
@@ -145,6 +149,7 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 				types.InviteScope,
 			},
 		},
@@ -173,6 +178,7 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 				types.InviteScope,
 			},
 		},

+ 101 - 0
api/server/router/middleware/usage.go

@@ -0,0 +1,101 @@
+package middleware
+
+import (
+	"fmt"
+	"net/http"
+
+	"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/usage"
+)
+
+type UsageMiddleware struct {
+	config *config.Config
+	metric types.UsageMetric
+}
+
+func NewUsageMiddleware(config *config.Config, metric types.UsageMetric) *UsageMiddleware {
+	return &UsageMiddleware{config, metric}
+}
+
+var UsageErrFmt = "usage limit reached for metric %s: limit %d, requested %d"
+
+func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+		// get the project usage limits
+		currentUsage, limit, _, err := usage.GetUsage(&usage.GetUsageOpts{
+			Project:          proj,
+			DOConf:           b.config.DOConf,
+			Repo:             b.config.Repo,
+			WhitelistedUsers: b.config.WhitelistedUsers,
+		})
+
+		if err != nil {
+			apierrors.HandleAPIError(
+				b.config,
+				w, r,
+				apierrors.NewErrInternal(err),
+				true,
+			)
+
+			return
+		}
+
+		// check the usage limits
+		allowed := allowUsage(limit, currentUsage, b.metric)
+
+		if allowed {
+			next.ServeHTTP(w, r)
+		} else {
+			limit, curr := getMetricUsage(limit, currentUsage, b.metric)
+
+			apierrors.HandleAPIError(
+				b.config,
+				w, r,
+				apierrors.NewErrPassThroughToClient(
+					fmt.Errorf(UsageErrFmt, b.metric, limit, curr),
+					http.StatusBadRequest,
+				),
+				true,
+			)
+		}
+	})
+}
+
+// checkUsage returns true if the increase in usage is allowed for the given metric,
+// false otherwise. We only assume increments of 1 in usage for now.
+func allowUsage(
+	plan, current *types.ProjectUsage,
+	metric types.UsageMetric,
+) bool {
+	switch metric {
+	case types.Users:
+		return plan.Users == 0 || plan.Users >= current.Users+1
+	case types.Clusters:
+		return plan.Clusters == 0 || plan.Clusters >= current.Clusters+1
+	default:
+		return false
+	}
+}
+
+func getMetricUsage(
+	plan, current *types.ProjectUsage,
+	metric types.UsageMetric,
+) (limit uint, curr uint) {
+	switch metric {
+	case types.CPU:
+		return plan.ResourceCPU, current.ResourceCPU
+	case types.Memory:
+		return plan.ResourceMemory, current.ResourceMemory
+	case types.Users:
+		return plan.Users, current.Users
+	case types.Clusters:
+		return plan.Clusters, current.Clusters
+	default:
+		return 0, 0
+	}
+}

+ 114 - 0
api/server/router/project.go

@@ -2,6 +2,7 @@ package router
 
 import (
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/project"
@@ -137,6 +138,113 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/usage -> project.NewProjectGetUsageHandler
+	getUsageEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/usage",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getUsageHandler := project.NewProjectGetUsageHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getUsageEndpoint,
+		Handler:  getUsageHandler,
+		Router:   r,
+	})
+
+	// 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",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getBillingHandler := project.NewProjectGetBillingHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingEndpoint,
+		Handler:  getBillingHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/billing/token -> billing.NewBillingGetTokenEndpoint
+	getBillingTokenEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	getBillingTokenHandler := billing.NewBillingGetTokenHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingTokenEndpoint,
+		Handler:  getBillingTokenHandler,
+		Router:   r,
+	})
+
+	// GET /api/billing_webhook -> billing.NewBillingWebhookHandler
+	getBillingWebhookEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/billing_webhook",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	getBillingWebhookHandler := billing.NewBillingWebhookHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingWebhookEndpoint,
+		Handler:  getBillingWebhookHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters -> cluster.NewClusterListHandler
 	listClusterEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -509,6 +617,8 @@ func getProjectRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 
@@ -565,6 +675,8 @@ func getProjectRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 
@@ -621,6 +733,8 @@ func getProjectRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 

+ 6 - 0
api/server/router/router.go

@@ -233,6 +233,12 @@ func registerRoutes(config *config.Config, routes []*Route) {
 			atomicGroup.Use(websocketMw.Middleware)
 		}
 
+		if route.Endpoint.Metadata.CheckUsage {
+			usageMW := middleware.NewUsageMiddleware(config, route.Endpoint.Metadata.UsageMetric)
+
+			atomicGroup.Use(usageMW.Middleware)
+		}
+
 		atomicGroup.Method(
 			string(route.Endpoint.Metadata.Method),
 			route.Endpoint.Metadata.Path.RelativePath,

+ 9 - 0
api/server/shared/apierrors/errors.go

@@ -90,12 +90,17 @@ func (e *ErrPassThroughToClient) GetStatusCode() int {
 	return e.statusCode
 }
 
+type ErrorOpts struct {
+	Code uint
+}
+
 func HandleAPIError(
 	config *config.Config,
 	w http.ResponseWriter,
 	r *http.Request,
 	err RequestError,
 	writeErr bool,
+	opts ...ErrorOpts,
 ) {
 	extErrorStr := err.ExternalError()
 
@@ -123,6 +128,10 @@ func HandleAPIError(
 			Error: extErrorStr,
 		}
 
+		if len(opts) > 0 {
+			resp.Code = opts[0].Code
+		}
+
 		// write the status code
 		w.WriteHeader(err.GetStatusCode())
 

+ 4 - 2
api/server/shared/apitest/config.go

@@ -5,10 +5,11 @@ import (
 	"testing"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
-	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/test"
 )
@@ -26,7 +27,7 @@ func (t *TestConfigLoader) LoadConfig() (*config.Config, error) {
 	l := logger.New(true, os.Stdout)
 	repo := test.NewRepository(t.canQuery, t.failingRepoMethods...)
 
-	envConf, err := loader.FromEnv()
+	envConf, err := envloader.FromEnv()
 
 	if err != nil {
 		return nil, err
@@ -57,6 +58,7 @@ func (t *TestConfigLoader) LoadConfig() (*config.Config, error) {
 		TokenConf:       tokenConf,
 		UserNotifier:    notifier,
 		AnalyticsClient: analytics.InitializeAnalyticsSegmentClient("", l),
+		BillingManager:  &billing.NoopBillingManager{},
 	}, nil
 }
 

+ 8 - 1
api/server/shared/config/config.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -83,7 +84,13 @@ type Config struct {
 	// AnalyticsClient if Segment analytics reporting is enabled on the API instance
 	AnalyticsClient analytics.AnalyticsSegmentClient
 
-	// PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
+	// BillingManager manages billing for Porter instances with billing enabled
+	BillingManager billing.BillingManager
+
+	// WhitelistedUsers do not count toward usage limits
+	WhitelistedUsers map[uint]uint
+
+  // PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
 	PowerDNSClient *powerdns.Client
 }
 

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

@@ -49,6 +49,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"`
+
 	DOClientID                 string `env:"DO_CLIENT_ID"`
 	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`

+ 1 - 1
api/server/shared/config/loader/envloader.go → api/server/shared/config/envloader/envloader.go

@@ -1,4 +1,4 @@
-package loader
+package envloader
 
 import (
 	"fmt"

+ 7 - 0
api/server/shared/config/loader/init_ce.go

@@ -0,0 +1,7 @@
+// +build !ee
+
+package loader
+
+func init() {
+	sharedInit()
+}

+ 41 - 0
api/server/shared/config/loader/init_ee.go

@@ -0,0 +1,41 @@
+// +build ee
+
+package loader
+
+import (
+	eeBilling "github.com/porter-dev/porter/ee/billing"
+	"github.com/porter-dev/porter/ee/models"
+	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
+	"github.com/porter-dev/porter/internal/billing"
+)
+
+func init() {
+	sharedInit()
+
+	InstanceDB.AutoMigrate(
+		&models.ProjectBilling{},
+		&models.UserBilling{},
+	)
+
+	var key [32]byte
+
+	for i, b := range []byte(InstanceEnvConf.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	eeRepo := eeGorm.NewEERepository(InstanceDB, &key)
+
+	if InstanceEnvConf.ServerConf.IronPlansAPIKey != "" && InstanceEnvConf.ServerConf.IronPlansServerURL != "" {
+		serverURL := InstanceEnvConf.ServerConf.IronPlansServerURL
+		apiKey := InstanceEnvConf.ServerConf.IronPlansAPIKey
+		var err error
+
+		InstanceBillingManager, err = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+
+		if err != nil {
+			panic(err)
+		}
+	} else {
+		InstanceBillingManager = &billing.NoopBillingManager{}
+	}
+}

+ 42 - 21
api/server/shared/config/loader/loader.go

@@ -9,11 +9,13 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -24,41 +26,51 @@ import (
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	lr "github.com/porter-dev/porter/internal/logger"
+
+	pgorm "gorm.io/gorm"
 )
 
-type EnvConfigLoader struct{}
+var InstanceBillingManager billing.BillingManager
+var InstanceEnvConf *envloader.EnvConf
+var InstanceDB *pgorm.DB
 
-func NewEnvLoader() config.ConfigLoader {
-	return &EnvConfigLoader{}
+type EnvConfigLoader struct {
+	version string
 }
 
-func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
-	envConf, err := FromEnv()
+func NewEnvLoader(version string) config.ConfigLoader {
+	return &EnvConfigLoader{version}
+}
 
-	if err != nil {
-		return nil, err
-	}
+func sharedInit() {
+	var err error
+	InstanceEnvConf, _ = envloader.FromEnv()
 
-	sc := envConf.ServerConf
+	InstanceDB, err = adapter.New(InstanceEnvConf.DBConf)
 
-	res = &config.Config{
-		Logger:     lr.NewConsole(sc.Debug),
-		ServerConf: sc,
-		DBConf:     envConf.DBConf,
-		RedisConf:  envConf.RedisConf,
+	if err != nil {
+		panic(err)
 	}
 
-	res.Metadata = config.MetadataFromConf(envConf.ServerConf)
+	InstanceBillingManager = &billing.NoopBillingManager{}
+}
 
-	db, err := adapter.New(envConf.DBConf)
+func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
+	envConf := InstanceEnvConf
+	sc := envConf.ServerConf
 
-	if err != nil {
-		return nil, err
+	res = &config.Config{
+		Logger:         lr.NewConsole(sc.Debug),
+		ServerConf:     sc,
+		DBConf:         envConf.DBConf,
+		RedisConf:      envConf.RedisConf,
+		BillingManager: InstanceBillingManager,
 	}
 
-	res.DB = db
+	res.Metadata = config.MetadataFromConf(envConf.ServerConf, e.version)
+	res.DB = InstanceDB
 
-	err = gorm.AutoMigrate(db)
+	err = gorm.AutoMigrate(InstanceDB)
 
 	if err != nil {
 		return nil, err
@@ -70,7 +82,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		key[i] = b
 	}
 
-	res.Repo = gorm.NewRepository(db, &key)
+	res.Repo = gorm.NewRepository(InstanceDB, &key)
 
 	// create the session store
 	res.Store, err = sessionstore.NewStore(
@@ -177,6 +189,15 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		},
 	}
 
+	// construct the whitelisted users map
+	wlUsers := make(map[uint]uint)
+
+	for _, userID := range sc.WhitelistedUsers {
+		wlUsers[userID] = userID
+	}
+
+	res.WhitelistedUsers = wlUsers
+
 	res.URLCache = urlcache.Init(sc.DefaultApplicationHelmRepoURL, sc.DefaultAddonHelmRepoURL)
 
 	provAgent, err := getProvisionerAgent(sc)

+ 11 - 9
api/server/shared/config/metadata.go

@@ -3,17 +3,18 @@ package config
 import "github.com/porter-dev/porter/api/server/shared/config/env"
 
 type Metadata struct {
-	Provisioning       bool `json:"provisioner"`
-	Github             bool `json:"github"`
-	BasicLogin         bool `json:"basic_login"`
-	GithubLogin        bool `json:"github_login"`
-	GoogleLogin        bool `json:"google_login"`
-	SlackNotifications bool `json:"slack_notifications"`
-	Email              bool `json:"email"`
-	Analytics          bool `json:"analytics"`
+	Provisioning       bool   `json:"provisioner"`
+	Github             bool   `json:"github"`
+	BasicLogin         bool   `json:"basic_login"`
+	GithubLogin        bool   `json:"github_login"`
+	GoogleLogin        bool   `json:"google_login"`
+	SlackNotifications bool   `json:"slack_notifications"`
+	Email              bool   `json:"email"`
+	Analytics          bool   `json:"analytics"`
+	Version            string `json:"version"`
 }
 
-func MetadataFromConf(sc *env.ServerConf) *Metadata {
+func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
 	return &Metadata{
 		// note: provisioning is set in the metadata after the loader is called
 		Provisioning:       false,
@@ -24,6 +25,7 @@ func MetadataFromConf(sc *env.ServerConf) *Metadata {
 		SlackNotifications: sc.SlackClientID != "" && sc.SlackClientSecret != "",
 		Email:              sc.SendgridAPIKey != "",
 		Analytics:          sc.SegmentClientKey != "",
+		Version:            version,
 	}
 }
 

+ 7 - 0
api/types/error.go

@@ -1,5 +1,12 @@
 package types
 
+const (
+	ErrCodeUnavailable uint = 601
+)
+
 type ExternalError struct {
+	// Optional error code for well-known error types
+	Code uint `json:"code,omitempty"`
+
 	Error string `json:"error"`
 }

+ 8 - 0
api/types/project.go

@@ -61,3 +61,11 @@ type DeleteRoleRequest struct {
 type DeleteRoleResponse struct {
 	*Role
 }
+
+type GetBillingTokenResponse struct {
+	Token string `json:"token"`
+}
+
+type GetProjectBillingResponse struct {
+	HasBilling bool `json:"has_billing"`
+}

+ 6 - 0
api/types/request.go

@@ -63,6 +63,12 @@ type APIRequestMetadata struct {
 
 	// Whether the endpoint upgrades to a websocket
 	IsWebsocket bool
+
+	// Whether the endpoint should check for a usage limit
+	CheckUsage bool
+
+	// The usage metric that the request should check for, if CheckUsage
+	UsageMetric UsageMetric
 }
 
 const RequestScopeCtxKey = "requestscopes"

+ 66 - 0
api/types/usage.go

@@ -0,0 +1,66 @@
+package types
+
+import "time"
+
+type UsageMetric string
+
+const (
+	CPU      UsageMetric = "cpu"
+	Memory   UsageMetric = "memory"
+	Clusters UsageMetric = "clusters"
+	Users    UsageMetric = "users"
+)
+
+type ProjectUsage struct {
+	// The CPU usage, in vCPUs
+	ResourceCPU uint `json:"resource_cpu"`
+
+	// The memory usage, in mibibytes (?)
+	ResourceMemory uint `json:"resource_memory"`
+
+	// The number of clusters
+	Clusters uint `json:"clusters"`
+
+	// The number of users
+	Users uint `json:"users"`
+}
+
+var BasicPlan = ProjectUsage{
+	ResourceCPU:    10,
+	ResourceMemory: 20000,
+	Clusters:       1,
+	Users:          1,
+}
+
+var TeamPlan = ProjectUsage{
+	ResourceCPU:    20,
+	ResourceMemory: 40000,
+	Clusters:       3,
+	Users:          3,
+}
+
+var GrowthPlan = ProjectUsage{
+	ResourceCPU:    80,
+	ResourceMemory: 160000,
+	Clusters:       0,
+	Users:          5,
+}
+
+// all unlimited
+var EnterprisePlan = ProjectUsage{
+	ResourceCPU:    0,
+	ResourceMemory: 0,
+	Clusters:       0,
+	Users:          0,
+}
+
+type GetProjectUsageResponse struct {
+	Current ProjectUsage `json:"current"`
+	Limit   ProjectUsage `json:"limit"`
+
+	// Whether the usage is exceeded
+	IsExceeded bool `json:"exceeded"`
+
+	// When the usage has been exceeded since, if IsExceeded
+	ExceededSince *time.Time `json:"exceeded_since,omitempty"`
+}

+ 2 - 2
cmd/app/main.go

@@ -14,7 +14,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "dev-ce"
 
 func main() {
 	var versionFlag bool
@@ -27,7 +27,7 @@ func main() {
 		os.Exit(0)
 	}
 
-	cl := loader.NewEnvLoader()
+	cl := loader.NewEnvLoader(Version)
 
 	config, err := cl.LoadConfig()
 

+ 2 - 2
cmd/migrate/main.go

@@ -3,7 +3,7 @@ package main
 import (
 	"log"
 
-	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
@@ -17,7 +17,7 @@ func main() {
 	logger := lr.NewConsole(true)
 	logger.Info().Msg("running migrations")
 
-	envConf, err := loader.FromEnv()
+	envConf, err := envloader.FromEnv()
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("could not load env conf")

+ 2 - 2
cmd/ready/main.go

@@ -5,14 +5,14 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/porter-dev/porter/api/server/shared/config/loader"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	lr "github.com/porter-dev/porter/internal/logger"
 )
 
 func main() {
 	logger := lr.NewConsole(true)
 
-	envConf, err := loader.FromEnv()
+	envConf, err := envloader.FromEnv()
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

+ 66 - 0
dashboard/package-lock.json

@@ -2496,6 +2496,47 @@
       "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
       "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
     },
+    "@ironplans/api": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@ironplans/api/-/api-0.4.0.tgz",
+      "integrity": "sha512-1bMPL5nNoUQKG8ZT+IqpC40TFkO95lvo0MIYs9R54iEG2T0iSGgUDwOT12pNvRV5pjYk1Xs49SW6x4XBkbrMZA=="
+    },
+    "@ironplans/react": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@ironplans/react/-/react-0.4.0.tgz",
+      "integrity": "sha512-ZeNGcd0+4NcQ9RuSGCmd1mbi7DpDSD5bG3UCMziC1W+3/aw2jDOXRsZWTFFxpW4AVL2AZlbngYPYsbutFPR0Yw==",
+      "requires": {
+        "@ironplans/sdk": "^0.4.0"
+      }
+    },
+    "@ironplans/sdk": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@ironplans/sdk/-/sdk-0.4.0.tgz",
+      "integrity": "sha512-eTtAY9QCMotrjP6uls/AH9bS9yh9e/iUZu0s5F57wB1hb05KziQL8J9z6wTfqznY0OF+eEOO6jdxJ7wWjLQJ4g==",
+      "requires": {
+        "@ironplans/api": "^0.4.0",
+        "@ironplans/types": "^0.0.30-alpha.0",
+        "js-cookie": "^3.0.1",
+        "jwt-decode": "^3.1.2",
+        "micro-typed-events": "^1.0.2",
+        "qs": "^6.10.1"
+      },
+      "dependencies": {
+        "qs": {
+          "version": "6.10.1",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
+          "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
+          "requires": {
+            "side-channel": "^1.0.4"
+          }
+        }
+      }
+    },
+    "@ironplans/types": {
+      "version": "0.0.30-alpha.0",
+      "resolved": "https://registry.npmjs.org/@ironplans/types/-/types-0.0.30-alpha.0.tgz",
+      "integrity": "sha512-7goQ8VKTO2ra90j29ZFUiR1mz/Jpz/sqF1S7fEjgOjynL4DQc4P5qTb+to8QbZJQAUSha+vNesCsbN59iNTEUw=="
+    },
     "@jest/types": {
       "version": "24.9.0",
       "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz",
@@ -7844,6 +7885,11 @@
       "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.6.0.tgz",
       "integrity": "sha512-wVdUBYQeY2gY73RIlPrysvpYx+2vheGo8Y1SNQv/BzHToWpAZzJU7Z6uheKMAe+GLSBig5/Ps2nxg/8tRB73xg=="
     },
+    "js-cookie": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz",
+      "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw=="
+    },
     "js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7975,6 +8021,11 @@
         "jss": "10.5.1"
       }
     },
+    "jwt-decode": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
+      "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
+    },
     "killable": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@@ -8200,6 +8251,11 @@
       "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
       "dev": true
     },
+    "micro-typed-events": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/micro-typed-events/-/micro-typed-events-1.0.2.tgz",
+      "integrity": "sha512-YfSk2bMe6P4l0m/EMKF0wAsXAAtnsu/EHJ0yvTX8TwmKVokJZQYbd8VsJ7Hy+7lSwSt0b6QHD2F0e6VciJ5a8w=="
+    },
     "micromatch": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
@@ -10095,6 +10151,16 @@
       "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
       "dev": true
     },
+    "side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "requires": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      }
+    },
     "signal-exit": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",

+ 1 - 0
dashboard/package.json

@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@ironplans/react": "^0.4.0",
     "@material-ui/core": "^4.11.3",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 2 - 2
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -425,7 +425,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
             <p>
               <b>{this.state.autoBuildpack.name}</b> buildpack was{" "}
               <a
-                href="https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+                href="https://docs.porter.run/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
                 target="_blank"
               >
                 detected automatically
@@ -440,7 +440,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
           <FlexWrapper>
             <UseButton onClick={this.handleContinue}>Continue</UseButton>
             <StatusWrapper
-              href="https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
+              href="https://docs.porter.run/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks"
               target="_blank"
             >
               <i className="material-icons">help_outline</i>

+ 1 - 0
dashboard/src/main/Main.tsx

@@ -68,6 +68,7 @@ export default class Main extends Component<PropsType, StateType> {
     api
       .getMetadata("", {}, {})
       .then((res) => {
+        this.context.setEdition(res.data?.version);
         this.setState({ local: !res.data?.provisioner });
       })
       .catch((err) => console.log(err));

+ 49 - 1
dashboard/src/main/home/Home.tsx

@@ -32,6 +32,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
 import discordLogo from "../../assets/discord.svg";
+import UsageWarningModal from "./modals/UsageWarningModal";
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -292,11 +293,40 @@ class Home extends Component<PropsType, StateType> {
     this.getMetadata();
   }
 
+  async checkIfProjectHasBilling(projectId: number) {
+    const res = await api.getHasBilling(
+      "<token>",
+      {},
+      { project_id: projectId }
+    );
+    this.context.setHasBillingEnabled(res.data?.has_billing);
+  }
+
   // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
   // 1. Make sure clicking cluster in drawer shows cluster-dashboard
   // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
   // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
+      this.checkIfProjectHasBilling(this?.context?.currentProject?.id);
+      api
+        .getUsage(
+          "<token>",
+          {},
+          { project_id: this.context?.currentProject?.id }
+        )
+        .then((res) => {
+          const usage = res.data;
+          this.context.setUsage(usage);
+          if (usage.exceeded) {
+            this.context.setCurrentModal("UsageWarningModal", {
+              usage,
+            });
+          }
+        })
+        .catch(console.log);
+    }
+
     if (
       prevProps.currentProject !== this.props.currentProject ||
       (!prevProps.currentCluster && this.props.currentCluster)
@@ -479,6 +509,7 @@ class Home extends Component<PropsType, StateType> {
             onRequestClose={() => setCurrentModal(null, null)}
             width="760px"
             height="650px"
+            title="Connecting to an Existing Cluster"
           >
             <ClusterInstructionsModal />
           </Modal>
@@ -491,6 +522,7 @@ class Home extends Component<PropsType, StateType> {
               onRequestClose={() => setCurrentModal(null, null)}
               width="565px"
               height="275px"
+              title="Cluster Settings"
             >
               <UpdateClusterModal
                 setRefreshClusters={(x: boolean) =>
@@ -503,7 +535,8 @@ class Home extends Component<PropsType, StateType> {
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             width="760px"
-            height="725px"
+            height="380px"
+            title="Add a New Integration"
           >
             <IntegrationsModal />
           </Modal>
@@ -513,6 +546,7 @@ class Home extends Component<PropsType, StateType> {
             onRequestClose={() => setCurrentModal(null, null)}
             width="760px"
             height="650px"
+            title="Connecting to an Image Registry"
           >
             <IntegrationsInstructionsModal />
           </Modal>
@@ -523,6 +557,7 @@ class Home extends Component<PropsType, StateType> {
               onRequestClose={() => setCurrentModal(null, null)}
               width="600px"
               height="220px"
+              title="Add Namespace"
             >
               <NamespaceModal />
             </Modal>
@@ -533,6 +568,7 @@ class Home extends Component<PropsType, StateType> {
               onRequestClose={() => setCurrentModal(null, null)}
               width="700px"
               height="280px"
+              title="Delete Namespace"
             >
               <DeleteNamespaceModal />
             </Modal>
@@ -552,11 +588,23 @@ class Home extends Component<PropsType, StateType> {
             onRequestClose={() => setCurrentModal(null, null)}
             width="760px"
             height="440px"
+            title="Account Settings"
           >
             <AccountSettingsModal />
           </Modal>
         )}
 
+        {currentModal === "UsageWarningModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="760px"
+            height="530px"
+            title="Usage Warning"
+          >
+            <UsageWarningModal />
+          </Modal>
+        )}
+
         {currentOverlay && (
           <ConfirmOverlay
             show={true}

+ 10 - 2
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -106,7 +106,10 @@ class Dashboard extends Component<PropsType, StateType> {
   renderTabContents = () => {
     if (this.currentTab() === "provisioner") {
       return <Provisioner setRefreshClusters={this.props.setRefreshClusters} />;
-    } else if (this.currentTab() === "create-cluster") {
+    } else if (
+      this.currentTab() === "create-cluster" &&
+      this.context.usage.current.clusters < this.context.usage.limit.clusters
+    ) {
       return (
         <>
           <Banner>
@@ -132,7 +135,12 @@ class Dashboard extends Component<PropsType, StateType> {
     let tabOptions = [{ label: "Project Overview", value: "overview" }];
 
     if (this.props.isAuthorized("cluster", "", ["get", "create"])) {
-      tabOptions.push({ label: "Create a Cluster", value: "create-cluster" });
+      if (
+        this.context?.usage?.current?.clusters <
+        this.context?.usage?.limit?.clusters
+      ) {
+        tabOptions.push({ label: "Create a Cluster", value: "create-cluster" });
+      }
     }
 
     tabOptions.push({ label: "Provisioner Status", value: "provisioner" });

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -325,7 +325,7 @@ class Templates extends Component<PropsType, StateType> {
         <TemplatesWrapper>
           <TitleSection>
             Launch
-            <a href="https://docs.porter.run/docs/applications" target="_blank">
+            <a href="https://docs.porter.run/docs/addons" target="_blank">
               <i className="material-icons">help_outline</i>
             </a>
           </TitleSection>

+ 0 - 72
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -1,7 +1,6 @@
 import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
-import close from "assets/close.png";
 import github from "assets/github.png";
 
 import { Context } from "../../../shared/Context";
@@ -42,15 +41,6 @@ const AccountSettingsModal = () => {
 
   return (
     <>
-      <CloseButton
-        onClick={() => {
-          setCurrentModal(null, null);
-        }}
-      >
-        <CloseButtonImg src={close} />
-      </CloseButton>
-      <ModalTitle>Account Settings</ModalTitle>
-
       <TabSelector
         options={tabOptions}
         currentTab={currentTab}
@@ -181,68 +171,6 @@ const GitIcon = styled.img`
   margin-left: 1px;
 `;
 
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-
-  > i {
-    background: none;
-    border-radius: 3px;
-    display: flex;
-    font-size: 18px;
-    margin-top: 1px;
-    margin-right: 10px;
-    padding: 1px;
-    align-items: center;
-    justify-content: center;
-    color: #ffffffaa;
-    border: 0;
-  }
-`;
-
-const Subtitle = styled.div`
-  margin-top: 23px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  margin-bottom: -10px;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
 const A = styled.a`
   color: #8590ff;
   text-decoration: underline;

+ 3 - 73
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -96,17 +96,7 @@ export default class ClusterInstructionsModal extends Component<
   render() {
     let { currentPage, currentTab } = this.state;
     return (
-      <StyledClusterInstructionsModal>
-        <CloseButton
-          onClick={() => {
-            this.context.setCurrentModal(null, null);
-          }}
-        >
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Connecting to an Existing Cluster</ModalTitle>
-
+      <>
         <TabSelector
           options={tabOptions}
           currentTab={currentTab}
@@ -139,7 +129,7 @@ export default class ClusterInstructionsModal extends Component<
             arrow_forward
           </i>
         </PageSection>
-      </StyledClusterInstructionsModal>
+      </>
     );
   }
 }
@@ -207,64 +197,4 @@ const Placeholder = styled.div`
 const Bold = styled.div`
   font-weight: 600;
   margin-bottom: 7px;
-`;
-
-const Subtitle = styled.div`
-  padding: 17px 0px 25px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  margin-top: 3px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledClusterInstructionsModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
+`;

+ 3 - 61
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -20,7 +20,7 @@ const DeleteNamespaceModal = () => {
   const [status, setStatus] = useState<string>(null as string);
   const deleteNamespace = () => {
     if (namespaceNameForDelition !== currentModalData.metadata.name) {
-      setStatus("Please insert the name of the namespace to confirm deletion");
+      setStatus("Please enter the name of this namespace to confirm deletion");
       return;
     }
 
@@ -47,16 +47,7 @@ const DeleteNamespaceModal = () => {
   };
 
   return (
-    <StyledUpdateProjectModal>
-      <CloseButton
-        onClick={() => {
-          setCurrentModal(null, null);
-        }}
-      >
-        <CloseButtonImg src={close} />
-      </CloseButton>
-
-      <ModalTitle>Delete Namespace</ModalTitle>
+    <>
       <Subtitle>
         Please insert the name of the namespace to delete it:
         <DangerText>{" " + currentModalData.metadata.name}</DangerText>
@@ -84,7 +75,7 @@ const DeleteNamespaceModal = () => {
         onClick={() => deleteNamespace()}
         status={status}
       />
-    </StyledUpdateProjectModal>
+    </>
   );
 };
 
@@ -133,55 +124,6 @@ const Subtitle = styled.div`
   margin-bottom: -10px;
 `;
 
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledUpdateProjectModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 30px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
-
 const Warning = styled.div`
   font-size: 13px;
   display: flex;

+ 5 - 52
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -80,15 +80,7 @@ const EditCollaboratorModal = () => {
   };
 
   return (
-    <StyledUpdateProjectModal>
-      <CloseButton
-        onClick={() => {
-          setCurrentModal(null, null);
-        }}
-      >
-        <CloseButtonImg src={close} />
-      </CloseButton>
-
+    <>
       <ModalTitle>
         Update {isInvite ? "Invite for" : "Collaborator"} {user?.email}
       </ModalTitle>
@@ -107,7 +99,7 @@ const EditCollaboratorModal = () => {
         onClick={() => handleUpdate()}
         status={status}
       />
-    </StyledUpdateProjectModal>
+    </>
   );
 };
 
@@ -130,50 +122,11 @@ const Subtitle = styled.div`
 `;
 
 const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: "Work Sans", sans-serif;
   font-size: 18px;
-  color: #ffffff;
+  font-weight: 500;
+  margin-bottom: 10px;
   user-select: none;
-  font-weight: 700;
-  align-items: center;
   position: relative;
   white-space: nowrap;
   text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledUpdateProjectModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 30px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
+`;

+ 3 - 109
dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx

@@ -47,17 +47,7 @@ export default class ClusterInstructionsModal extends Component<
   render() {
     let { currentPage, currentTab } = this.state;
     return (
-      <StyledClusterInstructionsModal>
-        <CloseButton
-          onClick={() => {
-            this.context.setCurrentModal(null, null);
-          }}
-        >
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Connecting to an Image Registry</ModalTitle>
-
+      <>
         <TabSelector
           options={tabOptions}
           currentTab={currentTab}
@@ -67,42 +57,13 @@ export default class ClusterInstructionsModal extends Component<
         />
 
         {this.renderPage()}
-      </StyledClusterInstructionsModal>
+      </>
     );
   }
 }
 
 ClusterInstructionsModal.contextType = Context;
 
-const PageCount = styled.div`
-  margin-right: 9px;
-  user-select: none;
-  letter-spacing: 2px;
-`;
-
-const PageSection = styled.div`
-  position: absolute;
-  bottom: 22px;
-  right: 20px;
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  color: #ffffff;
-  justify-content: flex-end;
-  user-select: none;
-
-  > i {
-    font-size: 18px;
-    margin-left: 2px;
-    cursor: pointer;
-    border-radius: 20px;
-    padding: 5px;
-    :hover {
-      background: #ffffff11;
-    }
-  }
-`;
-
 const Code = styled.div`
   background: #181b21;
   padding: 10px 15px;
@@ -116,13 +77,6 @@ const Code = styled.div`
   font-family: monospace;
 `;
 
-const A = styled.a`
-  color: #ffffff;
-  text-decoration: underline;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-`;
-
 const Placeholder = styled.div`
   color: #aaaabb;
   font-size: 13px;
@@ -135,64 +89,4 @@ const Placeholder = styled.div`
 const Bold = styled.div`
   font-weight: 600;
   margin-bottom: 7px;
-`;
-
-const Subtitle = styled.div`
-  padding: 10px 0px 20px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  margin-top: 3px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledClusterInstructionsModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
+`;

+ 7 - 63
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -46,7 +46,9 @@ export default class IntegrationsModal extends Component<PropsType, StateType> {
           integrationList[integration.service].icon;
         let disabled =
           integration.service === "kube" || integration.service === "dockerhub";
-        return (
+
+        if (!disabled) {
+          return (
           <IntegrationOption
             key={i}
             disabled={disabled}
@@ -61,28 +63,19 @@ export default class IntegrationsModal extends Component<PropsType, StateType> {
             <Label>{integrationList[integration.service].label}</Label>
           </IntegrationOption>
         );
+        }
       });
     }
   };
 
   render() {
     return (
-      <StyledIntegrationsModal>
-        <CloseButton
-          onClick={() => {
-            this.context.setCurrentModal(null, null);
-          }}
-        >
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Add a New Integration</ModalTitle>
+      <>
         <Subtitle>Select the service you would like to connect to.</Subtitle>
-
         <IntegrationsCatalog>
           {this.renderIntegrationsCatalog()}
         </IntegrationsCatalog>
-      </StyledIntegrationsModal>
+      </>
     );
   }
 }
@@ -134,53 +127,4 @@ const Subtitle = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledIntegrationsModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
+`;

+ 44 - 3
dashboard/src/main/home/modals/Modal.tsx

@@ -5,6 +5,7 @@ type PropsType = {
   onRequestClose: () => void;
   width?: string;
   height?: string;
+  title?: string;
 };
 
 type StateType = {};
@@ -38,6 +39,14 @@ export default class Modal extends Component<PropsType, StateType> {
     return (
       <Overlay>
         <StyledModal ref={this.wrapperRef} width={width} height={height}>
+          <CloseButton onClick={this.props.onRequestClose}>
+            <i className="material-icons">close</i>
+          </CloseButton>
+          { 
+            this.props.title && (
+              <ModalTitle>{this.props.title}</ModalTitle>
+            )
+          }
           {this.props.children}
         </StyledModal>
       </Overlay>
@@ -45,6 +54,36 @@ export default class Modal extends Component<PropsType, StateType> {
   }
 }
 
+const ModalTitle = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  margin-bottom: 10px;
+  user-select: none;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
 const Overlay = styled.div`
   position: fixed;
   margin: 0;
@@ -67,11 +106,13 @@ const StyledModal = styled.div`
   max-width: 80vw;
   height: ${(props: { width?: string; height?: string }) =>
     props.height ? props.height : "425px"};
-  border-radius: 7px;
-  border: 0;
-  background-color: #202227;
   overflow: visible;
   padding: 25px 32px;
+  font-size: 13px;
+  border-radius: 10px;
+  background: #202227;
+  border: 1px solid #ffffff55;
+  color: #ffffff;
   animation: floatInModal 0.5s 0s;
   @keyframes floatInModal {
     from {

+ 3 - 61
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -80,16 +80,7 @@ export default class NamespaceModal extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledUpdateProjectModal>
-        <CloseButton
-          onClick={() => {
-            this.context.setCurrentModal(null, null);
-          }}
-        >
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Add Namespace</ModalTitle>
+      <>
         <Subtitle>Name</Subtitle>
 
         <InputWrapper>
@@ -113,7 +104,7 @@ export default class NamespaceModal extends Component<PropsType, StateType> {
           onClick={() => this.createNamespace()}
           status={this.state.status}
         />
-      </StyledUpdateProjectModal>
+      </>
     );
   }
 }
@@ -156,53 +147,4 @@ const Subtitle = styled.div`
   white-space: nowrap;
   text-overflow: ellipsis;
   margin-bottom: -10px;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledUpdateProjectModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 30px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
+`;

+ 3 - 61
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -102,16 +102,7 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledUpdateProjectModal>
-        <CloseButton
-          onClick={() => {
-            this.context.setCurrentModal(null, null);
-          }}
-        >
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Cluster Settings</ModalTitle>
+      <>
         <Subtitle>Cluster name</Subtitle>
 
         <InputWrapper>
@@ -150,7 +141,7 @@ class UpdateClusterModal extends Component<PropsType, StateType> {
           onYes={this.handleDelete}
           onNo={() => this.setState({ showDeleteOverlay: false })}
         />
-      </StyledUpdateProjectModal>
+      </>
     );
   }
 }
@@ -232,53 +223,4 @@ const Subtitle = styled.div`
   white-space: nowrap;
   text-overflow: ellipsis;
   margin-bottom: -10px;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 0px 13px;
-  display: flex;
-  flex: 1;
-  font-family: Work Sans, sans-serif;
-  font-size: 18px;
-  color: #ffffff;
-  user-select: none;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  z-index: 1;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledUpdateProjectModal = styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 30px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;
+`;

+ 189 - 0
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -0,0 +1,189 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import Banner from "components/Banner";
+
+import { Context } from "shared/Context";
+import { Usage, UsageData } from "shared/types";
+import { Link } from "react-router-dom";
+
+type UsageKeys = keyof Usage;
+
+const ReadableNameMap: {
+  [key: string]: string;
+} = {
+  resource_cpu: "CPU Usage",
+  resource_memory: "Memory Usage",
+  clusters: "Clusters",
+  users: "Users",
+};
+
+const parseToReadableString = (
+  key: UsageKeys,
+  current: number,
+  limit: number
+) => {
+  switch (key) {
+    case "clusters":
+      return `${current} / ${limit} clusters`;
+    case `resource_cpu`:
+      return `${current} / ${limit} vCPU`;
+    case "resource_memory":
+      return `${current / 1000} / ${limit / 1000} GB`;
+    case "users":
+      return `${current} / ${limit} seats`;
+    default:
+      return `${current} / ${limit}`;
+  }
+};
+
+const getRemainingDays = (date: string) => {
+  const start = new Date(date);
+
+  const _second = 1000;
+  const _minute = _second * 60;
+  const _hour = _minute * 60;
+  const _day = _hour * 24;
+  const end = new Date(date);
+  end.setDate(end.getDate() + 7);
+
+  let distance = end.getTime() - start.getTime();
+
+  if (distance < 0) {
+    return;
+  }
+  const days = Math.floor(distance / _day);
+  const hours = Math.floor((distance % _day) / _hour);
+  const minutes = Math.floor((distance % _hour) / _minute);
+  if (days > 0) return `${days} ${days > 1 ? "days" : "day"}`;
+  if (hours > 0) return `${hours} ${hours > 1 ? "hours" : "hour"}`;
+  return `${minutes} ${minutes > 1 ? "minutes" : "minute"}`;
+};
+
+const UpgradeChartModal: React.FC<{}> = () => {
+  const { setCurrentModal, currentModalData } = useContext(Context);
+  const [usage, setUsage] = useState<UsageData>(null);
+
+  useEffect(() => {
+    if (currentModalData.usage) {
+      const currentUsage: UsageData = currentModalData.usage;
+      setUsage(currentUsage);
+    }
+  }, [currentModalData?.usage]);
+
+  if (!usage) {
+    return null;
+  }
+
+  return (
+    <>
+      <Br />
+      <Banner type="warning">
+        Your project is currently exceeding its resource usage limit.
+      </Banner>
+      <Br />
+      {usage !== null && (
+        <UsageSection>
+          {Object.keys(usage.current).map((key) => {
+            const label = ReadableNameMap[key];
+            const current = usage.current[key];
+            const limit = usage.limit[key];
+            const isExceeding = current > limit;
+            return (
+              <UsageBlock isRed={isExceeding}>
+                <Label isRed={isExceeding}>{label}</Label>
+                <Stat isRed={isExceeding}>
+                  {parseToReadableString(key as UsageKeys, current, limit)}
+                </Stat>
+              </UsageBlock>
+            );
+          })}
+        </UsageSection>
+      )}
+      <Helper>
+        You have <b>{getRemainingDays(usage.exceeded_since)}</b> to resolve this
+        issue before your access to the dashboard is restricted.
+      </Helper>
+      <Helper>
+        Have a question about billing? Email us at{" "}
+        <a target="_blank" href="mailto:contact@porter.run">
+          contact@porter.run
+        </a>
+        .
+      </Helper>
+      <Button
+        as={Link}
+        to={{
+          pathname: "/project-settings",
+          search: "?selected_tab=billing",
+        }}
+        onClick={() => setCurrentModal(null, null)}
+      >
+        Take me to billing
+      </Button>
+    </>
+  );
+};
+
+export default UpgradeChartModal;
+
+const UsageBlock = styled.div<{ isRed?: boolean }>`
+  border: 1px solid ${(props) => (props.isRed ? "#ff385d" : "#ffffff55")};
+  border-radius: 5px;
+  padding: 18px;
+`;
+
+const Helper = styled.div`
+  color: #aaaabb;
+  margin-top: 12px;
+`;
+
+const Label = styled.div<{ isRed?: boolean }>`
+  margin-bottom: 10px;
+  font-weight: 500;
+  color: ${(props) => (props.isRed ? "#ff385d" : "#ffffff55")};
+`;
+
+const Stat = styled.div<{ isRed?: boolean }>`
+  font-size: 20px;
+  margin-bottom: 25px;
+  color: ${(props) => (props.isRed ? "#ff385d" : "#ffffff55")};
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 5px;
+`;
+
+const Button = styled.button`
+  height: 35px;
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: "pointer";
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+
+  width: max-content;
+  position: absolute;
+  right: 20px;
+  bottom: 20px;
+`;
+
+const UsageSection = styled.div`
+  margin-top: 10px;
+  margin-bottom: 35px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(2, minmax(200px, 1fr));
+`;

+ 66 - 0
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -0,0 +1,66 @@
+import React, { useContext, useEffect, useState } from "react";
+import { CustomerProvider, PlanSelect } from "@ironplans/react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+function BillingPage() {
+  const [customerToken, setCustomerToken] = useState("");
+  const { currentProject, setCurrentError, queryUsage } = useContext(Context);
+
+  useEffect(() => {
+    let isSubscripted = true;
+    api
+      .getCustomerToken("<token>", {}, { project_id: currentProject?.id })
+      .then((res) => {
+        if (isSubscripted) {
+          const token = res?.data?.token;
+          setCustomerToken(token);
+        }
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+    return () => {
+      isSubscripted = false;
+      queryUsage();
+    };
+  }, [currentProject?.id]);
+
+  return (
+    <div style={{ height: "1000px" }}>
+      <CustomerProvider token={customerToken}>
+        <PlanSelect
+          theme={{
+            base: {
+              customFont: "Work Sans",
+              fontFamily: '"Work Sans", sans-serif',
+              darkMode: "on",
+              colors: {
+                primary: "rgba(97, 111, 238, 0.8)",
+                secondary: "rgb(103, 108, 124)",
+                danger: "rgb(227, 54, 109)",
+                success: "rgb(56, 168, 138)",
+              },
+            },
+            card: {
+              backgroundColor: "rgb(38, 40, 47)",
+              boxShadow: "rgb(0 0 0 / 33%) 0px 4px 15px 0px",
+              borderRadius: "8px",
+              border: "2px solid rgba(158, 180, 255, 0)",
+            },
+            button: {
+              base: {
+                boxShadow: "rgb(0 0 0 / 19%) 0px 2px 5px 0px",
+                borderRadius: "5px",
+                fontSize: "14px",
+                fontWeight: "500",
+              },
+            },
+          }}
+        ></PlanSelect>
+      </CustomerProvider>
+    </div>
+  );
+}
+
+export default BillingPage;

+ 70 - 30
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -25,7 +25,14 @@ export type Collaborator = {
 };
 
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject, setCurrentModal, user } = useContext(Context);
+  const {
+    currentProject,
+    setCurrentModal,
+    setCurrentError,
+    user,
+    edition,
+    usage,
+  } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
@@ -115,7 +122,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         getData();
         setEmail("");
       })
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        if (err.response.data?.error) {
+          setCurrentError(err.response.data?.error);
+        }
+
+        console.log(err);
+      });
   };
 
   const deleteInvite = (inviteId: number) => {
@@ -154,7 +167,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         )
       )
       .then(getData)
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        if (err.response.data?.error) {
+          setCurrentError(err.response.data?.error);
+        }
+
+        console.log(err);
+      });
   };
 
   const validateEmail = () => {
@@ -346,35 +365,56 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     return mappedInviteList || [];
   }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
+  const hasSeats = () => {
+    // If usage limit is 0, the project has unlimited seats. Otherwise, check
+    // the usage limit against the current usage.
+    if (usage?.limit.users === 0) {
+      return true;
+    }
+
+    return usage?.current.users < usage?.limit.users;
+  };
+
+  if (!usage) {
+    <Loading height={"30%"} />;
+  }
+
   return (
     <>
-      <Heading isAtTop={true}>Share Project</Heading>
-      <Helper>Generate a project invite for another user.</Helper>
-      <InputRowWrapper>
-        <InputRow
-          value={email}
-          type="text"
-          setValue={(newEmail: string) => setEmail(newEmail)}
-          width="100%"
-          placeholder="ex: mrp@getporter.dev"
-        />
-      </InputRowWrapper>
-      <Helper>Specify a role for this user.</Helper>
-      <RoleSelectorWrapper>
-        <RadioSelector
-          selected={role}
-          setSelected={setRole}
-          options={roleList}
-        />
-      </RoleSelectorWrapper>
-      <ButtonWrapper>
-        <InviteButton disabled={false} onClick={() => validateEmail()}>
-          Create Invite
-        </InviteButton>
-        {isInvalidEmail && (
-          <Invalid>Invalid email address. Please try again.</Invalid>
-        )}
-      </ButtonWrapper>
+      <>
+        <Heading isAtTop={true}>Share Project</Heading>
+        <Helper>Generate a project invite for another user.</Helper>
+        <InputRowWrapper>
+          <InputRow
+            value={email}
+            type="text"
+            setValue={(newEmail: string) => setEmail(newEmail)}
+            width="100%"
+            placeholder="ex: mrp@getporter.dev"
+          />
+        </InputRowWrapper>
+        <Helper>Specify a role for this user.</Helper>
+        <RoleSelectorWrapper>
+          <RadioSelector
+            selected={role}
+            setSelected={setRole}
+            options={roleList}
+          />
+        </RoleSelectorWrapper>
+        <ButtonWrapper>
+          <InviteButton disabled={!hasSeats()} onClick={() => validateEmail()}>
+            Create Invite
+          </InviteButton>
+          {isInvalidEmail && (
+            <Invalid>Invalid email address. Please try again.</Invalid>
+          )}
+          {!hasSeats() && (
+            <Invalid>
+              You need to upgrade your plan to invite more users to the project
+            </Invalid>
+          )}
+        </ButtonWrapper>
+      </>
 
       <Heading>Invites & Collaborators</Heading>
       <Helper>Manage pending invites and view collaborators.</Helper>

+ 55 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -9,8 +9,11 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
+import { getQueryParam } from "shared/routing";
+import BillingPage from "./BillingPage";
 
-type PropsType = WithAuthProps & {};
+type PropsType = RouteComponentProps & WithAuthProps & {};
 
 type StateType = {
   projectName: string;
@@ -25,12 +28,50 @@ class ProjectSettings extends Component<PropsType, StateType> {
     tabOptions: [] as { value: string; label: string }[],
   };
 
+  componentDidUpdate(prevProps: PropsType) {
+    const selectedTab = getQueryParam(this.props, "selected_tab");
+    if (prevProps.location.search !== this.props.location.search) {
+      if (selectedTab) {
+        this.setState({ currentTab: selectedTab });
+      } else {
+        this.setState({ currentTab: "manage-access" });
+      }
+    }
+    if (
+      this.context?.hasBillingEnabled &&
+      !this.state.tabOptions.find((t) => t.value === "billing")
+    ) {
+      const tabOptions = this.state.tabOptions;
+      tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
+      this.setState({ tabOptions });
+      return;
+    }
+
+    if (
+      !this.context?.hasBillingEnabled &&
+      this.state.tabOptions.find((t) => t.value === "billing")
+    ) {
+      const tabOptions = this.state.tabOptions;
+      const billingIndex = this.state.tabOptions.findIndex(
+        (t) => t.value === "billing"
+      );
+      tabOptions.splice(billingIndex, 1);
+    }
+  }
+
   componentDidMount() {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
     const tabOptions = [];
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
+
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
+      if (this.context?.hasBillingEnabled) {
+        tabOptions.push({
+          value: "billing",
+          label: "Billing",
+        });
+      }
       tabOptions.push({
         value: "additional-settings",
         label: "Additional Settings",
@@ -38,6 +79,11 @@ class ProjectSettings extends Component<PropsType, StateType> {
     }
 
     this.setState({ tabOptions });
+
+    const selectedTab = getQueryParam(this.props, "selected_tab");
+    if (selectedTab) {
+      this.setState({ currentTab: selectedTab });
+    }
   }
 
   renderTabContents = () => {
@@ -45,6 +91,13 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
+    if (
+      this.state.currentTab === "billing" &&
+      this.context?.hasBillingEnabled
+    ) {
+      return <BillingPage />;
+    }
+
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
     } else {
@@ -107,7 +160,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
 ProjectSettings.contextType = Context;
 
-export default withAuth(ProjectSettings);
+export default withRouter(withAuth(ProjectSettings));
 
 const Warning = styled.div`
   font-size: 13px;

+ 41 - 1
dashboard/src/shared/Context.tsx

@@ -5,9 +5,11 @@ import {
   ClusterType,
   ContextProps,
   ProjectType,
+  UsageData,
 } from "shared/types";
 
 import { pushQueryParams } from "shared/routing";
+import api from "./api";
 
 const Context = React.createContext<Partial<ContextProps>>(null);
 
@@ -49,6 +51,13 @@ export interface GlobalContextType {
   capabilities: CapabilityType;
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
+  edition: "ee" | "ce";
+  setEdition: (appVersion: string) => void;
+  hasBillingEnabled: boolean;
+  setHasBillingEnabled: (isBillingEnabled: boolean) => void;
+  usage: UsageData;
+  setUsage: (usage: UsageData) => void;
+  queryUsage: (retry?: number) => Promise<void>;
 }
 
 /**
@@ -135,10 +144,41 @@ class ContextProvider extends Component<PropsType, StateType> {
         devOpsMode: true,
       });
     },
+    edition: "ce",
+    setEdition: (version: string) => {
+      const [edition] = version.split("-").reverse();
+      // typesafe just in case we mess up something it will default to ce
+      if (edition === "ce" || edition === "ee") {
+        this.setState({ edition });
+      }
+    },
+    hasBillingEnabled: null,
+    setHasBillingEnabled: (isBillingEnabled: boolean) => {
+      this.setState({ hasBillingEnabled: isBillingEnabled });
+    },
+    usage: null,
+    setUsage: (usage: UsageData) => {
+      this.setState({ usage });
+    },
+    queryUsage: async (retry: number = 0) => {
+      api
+        .getUsage("<token>", {}, { project_id: this.state?.currentProject?.id })
+        .then((res) => {
+          if (JSON.stringify(res.data) !== JSON.stringify(this.state.usage)) {
+            this.state.setUsage(res.data);
+          } else {
+            if (retry < 10) {
+              setTimeout(() => {
+                this.state.queryUsage(retry + 1);
+              }, 1000);
+            }
+          }
+        });
+    },
   };
 
   render() {
-    return <Provider value={this.state}>{this.props.children}</Provider>;
+    return <Provider value={{ ...this.state }}>{this.props.children}</Provider>;
   }
 }
 

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

@@ -1044,6 +1044,22 @@ const createWebhookToken = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${chart_name}/0/webhook`
 );
 
+const getUsage = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/usage`
+);
+
+// Used for billing purposes
+const getCustomerToken = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/billing/token`
+);
+
+const getHasBilling = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/billing`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1149,4 +1165,7 @@ export default {
   removeCollaborator,
   getPolicyDocument,
   createWebhookToken,
+  getUsage,
+  getCustomerToken,
+  getHasBilling,
 };

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

@@ -296,6 +296,13 @@ export interface ContextProps {
   capabilities: CapabilityType;
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
+  edition: "ee" | "ce";
+  setEdition: (appVersion: string) => void;
+  hasBillingEnabled: boolean;
+  setHasBillingEnabled: (isBillingEnabled: boolean) => void;
+  usage: UsageData;
+  setUsage: (usage: UsageData) => void;
+  queryUsage: () => Promise<void>;
 }
 
 export enum JobStatusType {
@@ -308,3 +315,17 @@ export interface JobStatusWithTimeType {
   status: JobStatusType;
   start_time: string;
 }
+
+export interface Usage {
+  resource_cpu: number;
+  resource_memory: number;
+  clusters: number;
+  users: number;
+}
+
+export interface UsageData {
+  current: Usage & { [key: string]: number };
+  limit: Usage & { [key: string]: number };
+  exceeds: boolean;
+  exceeded_since?: string;
+}

+ 1 - 1
docs/guides/using-env-groups.md

@@ -20,7 +20,7 @@ You will be redirected to the list of environment groups, and your new environme
 
 ![Load env group](https://files.readme.io/c909d6a-env-groups-4.png "env-groups-4.png")
 
-You can then select your environment group and click "Load Selected Env Group", which will automatically populate the environment group variables that you previously set. You can modify these environment variables in this tab, for example if you'd like to add environment variables that aren't currently in the environment group. To view all deployment options, head over to our [application deployment docs](https://docs.getporter.dev/docs/add-ons). 
+You can then select your environment group and click "Load Selected Env Group", which will automatically populate the environment group variables that you previously set. You can modify these environment variables in this tab, for example if you'd like to add environment variables that aren't currently in the environment group. To view all deployment options, head over to our [application deployment docs](https://docs.porter.run/docs/addons). 
 
 # 🔒 Creating secret environment variables
 

+ 36 - 0
ee/LICENSE

@@ -0,0 +1,36 @@
+The Porter Enterprise license (the “Enterprise License”)
+Copyright (c) 2021-present Porter Technologies Inc.
+
+With regard to the Porter Software:
+
+This software and associated documentation files (the "Software") may only be
+used in production, if you (and any entity that you represent) have agreed to,
+and are in compliance with, the Porter Terms of Service, available at 
+https://docs.porter.run/docs/terms-of-service (the “Enterprise Terms”), or other
+agreement governing the use of the Software, as agreed by you and Porter,
+and otherwise have a valid Porter Enterprise license for the
+correct number of user seats. Subject to the foregoing sentence, you are free to
+modify this Software and publish patches to the Software. You agree that Porter
+and/or its licensors (as applicable) retain all right, title and interest in and
+to all such modifications and/or patches, and all such modifications and/or
+patches may only be used, copied, modified, displayed, distributed, or otherwise
+exploited with a valid Porter Enterprise license for the  correct
+number of user seats.  Notwithstanding the foregoing, you may copy and modify
+the Software for development and testing purposes, without requiring a
+subscription.  You agree that Porter and/or its licensors (as applicable) retain
+all right, title and interest in and to all such modifications.  You are not
+granted any other rights beyond what is expressly stated herein.  Subject to the
+foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
+and/or sell the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+For all third party components incorporated into the Porter Software, those
+components are licensed under the original license provided by the owner of the
+applicable component.

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

@@ -0,0 +1,65 @@
+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, err := c.Config().BillingManager.GetIDToken(proj.ID, user)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetBillingTokenResponse{
+		Token: token,
+	})
+}

+ 84 - 0
ee/api/server/handlers/billing/webhook.go

@@ -0,0 +1,84 @@
+package billing
+
+import (
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"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"
+	"gorm.io/gorm"
+)
+
+type BillingWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewBillingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return &BillingWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// verify webhook secret
+	signature := r.Header.Get("x-signature")
+
+	if !c.Config().BillingManager.VerifySignature(signature, payload) {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+			fmt.Errorf("could not verify signature for billing webhook"),
+		))
+
+		return
+	}
+
+	// parse usage and update project
+	newUsage, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// newUsage will be nil if webhook event type is not "subscription", so return without
+	// updating usage in this case
+	if newUsage == nil {
+		return
+	}
+
+	// update the project's usage
+	existingUsage, err := c.Repo().ProjectUsage().ReadProjectUsage(newUsage.ProjectID)
+	notFound := errors.Is(err, gorm.ErrRecordNotFound)
+
+	if !notFound && err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if notFound {
+		_, err = c.Repo().ProjectUsage().CreateProjectUsage(newUsage)
+	} else {
+		newUsage.ID = existingUsage.ID
+		_, err = c.Repo().ProjectUsage().UpdateProjectUsage(newUsage)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 26 - 3
api/server/handlers/invite/accept.go → ee/api/server/handlers/invite/accept.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -21,7 +23,7 @@ type InviteAcceptHandler struct {
 
 func NewInviteAcceptHandler(
 	config *config.Config,
-) *InviteAcceptHandler {
+) http.Handler {
 	return &InviteAcceptHandler{
 		PorterHandler: handlers.NewDefaultPorterHandler(config, nil, nil),
 	}
@@ -81,13 +83,15 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		kind = models.RoleDeveloper
 	}
 
-	if _, err = c.Repo().Project().CreateProjectRole(proj, &models.Role{
+	role := &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
 			Kind:      types.RoleKind(kind),
 		},
-	}); err != nil {
+	}
+
+	if role, err = c.Repo().Project().CreateProjectRole(proj, role); err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -100,5 +104,24 @@ 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)
 }

+ 3 - 1
api/server/handlers/invite/create.go → ee/api/server/handlers/invite/create.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -23,7 +25,7 @@ func NewInviteCreateHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *InviteCreateHandler {
+) http.Handler {
 	return &InviteCreateHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 	}

+ 3 - 1
api/server/handlers/invite/delete.go → ee/api/server/handlers/invite/delete.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -18,7 +20,7 @@ type InviteDeleteHandler struct {
 
 func NewInviteDeleteHandler(
 	config *config.Config,
-) *InviteDeleteHandler {
+) http.Handler {
 	return &InviteDeleteHandler{
 		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
 		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),

+ 3 - 1
api/server/handlers/invite/list.go → ee/api/server/handlers/invite/list.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -18,7 +20,7 @@ type InvitesListHandler struct {
 func NewInvitesListHandler(
 	config *config.Config,
 	writer shared.ResultWriter,
-) *InvitesListHandler {
+) http.Handler {
 	return &InvitesListHandler{
 		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 	}

+ 3 - 1
api/server/handlers/invite/update_role.go → ee/api/server/handlers/invite/update_role.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -18,7 +20,7 @@ type InviteUpdateRoleHandler struct {
 func NewInviteUpdateRoleHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
-) *InviteUpdateRoleHandler {
+) http.Handler {
 	return &InviteUpdateRoleHandler{
 		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
 	}

+ 477 - 0
ee/billing/ironplans.go

@@ -0,0 +1,477 @@
+// +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
+
+	defaultPlan *Plan
+}
+
+// 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, nil}
+
+	// get the default plans from the IronPlans API server
+	listResp := &ListPlansResponse{}
+	err := client.getRequest("/plans/v1", listResp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, plan := range listResp.Results {
+		if plan.Name == "Free" {
+			copyPlan := plan
+			client.defaultPlan = &copyPlan
+		}
+	}
+
+	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.defaultPlan != nil {
+		err := c.postRequest("/subscriptions/v1", &CreateSubscriptionRequest{
+			PlanID:     c.defaultPlan.ID,
+			NextPlanID: c.defaultPlan.ID,
+			TeamID:     resp.ID,
+			IsPaused:   false,
+		}, nil)
+
+		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) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
+	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(projectID uint, user *cemodels.User) (token string, err error) {
+	// attempt to read the user billing data from the
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(projectID, 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, nil
+			}
+		}
+	}
+
+	req := &CreateIDTokenRequest{
+		Email:  user.Email,
+		UserID: fmt.Sprintf("%d-%d", projectID, 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: projectID,
+			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: projectID,
+			UserID:    user.ID,
+			Token:     []byte(token),
+		})
+
+		if err != nil {
+			return "", err
+		}
+	}
+
+	return token, 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{}) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	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())
+}

+ 98 - 0
ee/billing/types.go

@@ -0,0 +1,98 @@
+// +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 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 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 Feature struct {
+	Slug string `json:"slug"`
+}
+
+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"`
+}
+
+type CreateIDTokenRequest struct {
+	Email  string `json:"customer_email"`
+	UserID string `json:"customer_source_id"`
+}
+
+type CreateIDTokenResponse struct {
+	Token string `json:"token"`
+}
+
+type SubscriptionWebhookRequest struct {
+	EventType string `json:"event_type"`
+	TeamID    string `json:"team_id"`
+	Plan      Plan   `json:"plan"`
+}
+
+type CreateSubscriptionRequest struct {
+	PlanID     string `json:"plan_id"`
+	TeamID     string `json:"team_id"`
+	IsPaused   bool   `json:"is_paused"`
+	NextPlanID string `json:"next_plan_id"`
+}

+ 75 - 0
ee/docker/ee.Dockerfile

@@ -0,0 +1,75 @@
+# syntax=docker/dockerfile:1.1.7-experimental
+
+# Base Go environment
+# -------------------
+FROM golang:1.15-alpine as base
+WORKDIR /porter
+
+RUN apk update && apk add --no-cache gcc musl-dev git
+
+COPY go.mod go.sum ./
+COPY /cmd ./cmd
+COPY /internal ./internal
+COPY /api ./api
+COPY /ee ./ee
+
+RUN --mount=type=cache,target=$GOPATH/pkg/mod \
+    go mod download
+
+# Go build environment
+# --------------------
+FROM base AS build-go
+
+ARG version=production
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go build -ldflags="-w -s -X 'main.Version=${version}'" -tags ee -a -o ./bin/app ./cmd/app && \
+    go build -ldflags '-w -s' -a -tags ee -o ./bin/migrate ./cmd/migrate && \
+    go build -ldflags '-w -s' -a -tags ee -o ./bin/ready ./cmd/ready
+
+# Go test environment
+# -------------------
+FROM base AS porter-test
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go test ./...
+
+# Webpack build environment
+# -------------------------
+FROM node:latest as build-webpack
+WORKDIR /webpack
+
+COPY ./dashboard ./
+
+RUN npm i
+
+ENV NODE_ENV=production
+
+RUN npm run build
+
+# Deployment environment
+# ----------------------
+FROM alpine
+RUN apk update
+
+COPY --from=build-go /porter/bin/app /porter/
+COPY --from=build-go /porter/bin/migrate /porter/
+COPY --from=build-go /porter/bin/ready /porter/
+COPY --from=build-webpack /webpack/build /porter/static
+
+ENV DEBUG=false
+ENV STATIC_FILE_PATH=/porter/static
+ENV SERVER_PORT=8080
+ENV SERVER_TIMEOUT_READ=5s
+ENV SERVER_TIMEOUT_WRITE=10s
+ENV SERVER_TIMEOUT_IDLE=15s
+
+ENV COOKIE_SECRETS=secret
+
+ENV SQL_LITE=true
+ENV ADMIN_INIT=false
+
+EXPOSE 8080
+CMD /porter/migrate && /porter/app

+ 13 - 0
ee/models/project_billing.go

@@ -0,0 +1,13 @@
+// +build ee
+
+package models
+
+import "gorm.io/gorm"
+
+// ProjectBilling stores a billing data per project
+type ProjectBilling struct {
+	*gorm.Model
+
+	ProjectID     uint
+	BillingTeamID string
+}

+ 15 - 0
ee/models/user_billing.go

@@ -0,0 +1,15 @@
+// +build ee
+
+package models
+
+import "gorm.io/gorm"
+
+// UserBilling stores a billing token per user in a project
+type UserBilling struct {
+	*gorm.Model
+
+	ProjectID  uint
+	UserID     uint
+	TeammateID string
+	Token      []byte
+}

+ 46 - 0
ee/repository/gorm/project_billing.go

@@ -0,0 +1,46 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectBillingRepository uses gorm.DB for querying the database
+type ProjectBillingRepository struct {
+	db *gorm.DB
+}
+
+func NewProjectBillingRepository(db *gorm.DB) repository.ProjectBillingRepository {
+	return &ProjectBillingRepository{db}
+}
+
+func (repo *ProjectBillingRepository) CreateProjectBilling(projBilling *models.ProjectBilling) (*models.ProjectBilling, error) {
+	if err := repo.db.Create(projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}
+
+func (repo *ProjectBillingRepository) ReadProjectBillingByProjectID(projID uint) (*models.ProjectBilling, error) {
+	projBilling := &models.ProjectBilling{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(&projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}
+
+func (repo *ProjectBillingRepository) ReadProjectBillingByTeamID(teamID string) (*models.ProjectBilling, error) {
+	projBilling := &models.ProjectBilling{}
+
+	if err := repo.db.Where("billing_team_id = ?", teamID).First(&projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}

+ 29 - 0
ee/repository/gorm/repository.go

@@ -0,0 +1,29 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+)
+
+type GormRepository struct {
+	userBilling repository.UserBillingRepository
+	projBilling repository.ProjectBillingRepository
+}
+
+func (t *GormRepository) UserBilling() repository.UserBillingRepository {
+	return t.userBilling
+}
+
+func (t *GormRepository) ProjectBilling() repository.ProjectBillingRepository {
+	return t.projBilling
+}
+
+// NewEERepository returns an EERepository
+func NewEERepository(db *gorm.DB, key *[32]byte) repository.EERepository {
+	return &GormRepository{
+		userBilling: NewUserBillingRepository(db, key),
+		projBilling: NewProjectBillingRepository(db),
+	}
+}

+ 116 - 0
ee/repository/gorm/user_billing.go

@@ -0,0 +1,116 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	cerepository "github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// UserBillingRepository uses gorm.DB for querying the database
+type UserBillingRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+func NewUserBillingRepository(db *gorm.DB, key *[32]byte) repository.UserBillingRepository {
+	return &UserBillingRepository{db, key}
+}
+
+// CreateUserBilling adds a new User row to the Users table in the database
+func (repo *UserBillingRepository) CreateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error) {
+	err := repo.EncryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+func (repo *UserBillingRepository) ReadUserBilling(projectID, userID uint) (*models.UserBilling, error) {
+	userBilling := &models.UserBilling{}
+
+	if err := repo.db.Where("project_id = ? AND user_id = ?", projectID, userID).First(&userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+// UpdateUserBilling updates user billing in the db
+func (repo *UserBillingRepository) UpdateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error) {
+	err := repo.EncryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+// EncryptUserBillingData will encrypt the user's billing data before writing
+// to the DB
+func (repo *UserBillingRepository) EncryptUserBillingData(
+	userBilling *models.UserBilling,
+	key *[32]byte,
+) error {
+	if tok := userBilling.Token; len(tok) > 0 {
+		cipherData, err := cerepository.Encrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		userBilling.Token = cipherData
+	}
+
+	return nil
+}
+
+// DecryptUserBillingData will decrypt the user's billing data before returning it
+// from the DB
+func (repo *UserBillingRepository) DecryptUserBillingData(
+	userBilling *models.UserBilling,
+	key *[32]byte,
+) error {
+	if tok := userBilling.Token; len(tok) > 0 {
+		plaintext, err := cerepository.Decrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		userBilling.Token = plaintext
+	}
+
+	return nil
+}

+ 11 - 0
ee/repository/project_billing.go

@@ -0,0 +1,11 @@
+// +build ee
+
+package repository
+
+import "github.com/porter-dev/porter/ee/models"
+
+type ProjectBillingRepository interface {
+	CreateProjectBilling(userBilling *models.ProjectBilling) (*models.ProjectBilling, error)
+	ReadProjectBillingByProjectID(projectID uint) (*models.ProjectBilling, error)
+	ReadProjectBillingByTeamID(teamID string) (*models.ProjectBilling, error)
+}

+ 8 - 0
ee/repository/repository.go

@@ -0,0 +1,8 @@
+// +build ee
+
+package repository
+
+type EERepository interface {
+	UserBilling() UserBillingRepository
+	ProjectBilling() ProjectBillingRepository
+}

+ 11 - 0
ee/repository/user_billing.go

@@ -0,0 +1,11 @@
+// +build ee
+
+package repository
+
+import "github.com/porter-dev/porter/ee/models"
+
+type UserBillingRepository interface {
+	CreateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error)
+	ReadUserBilling(projectID, userID uint) (*models.UserBilling, error)
+	UpdateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error)
+}

+ 29 - 0
ee/usage/limit.go

@@ -0,0 +1,29 @@
+// +build ee
+
+package usage
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+func GetLimit(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error) {
+	// query for the project limit; if not found, no limits
+	limitModel, err := repo.ProjectUsage().ReadProjectUsage(proj.ID)
+
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		// place existing users without usage on enterprise plan
+		copyBasic := types.EnterprisePlan
+		limit = &copyBasic
+	} else if err != nil {
+		return nil, err
+	} else {
+		limit = limitModel.ToProjectUsageType()
+	}
+
+	return limit, nil
+}

+ 82 - 0
internal/billing/billing.go

@@ -0,0 +1,82 @@
+package billing
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// BillingManager contains methods for managing billing for a project
+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)
+
+	// DeleteTeam deletes a billing team.
+	DeleteTeam(proj *models.Project) (err error)
+
+	// GetTeamID gets the billing team id for a project
+	GetTeamID(proj *models.Project) (teamID string, err 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(projectID uint, user *models.User) (token string, err error)
+
+	// ParseProjectUsageFromWebhook parses the project usage from a webhook payload sent
+	// from a billing agent
+	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error)
+
+	// VerifySignature verifies the signature for a webhook
+	VerifySignature(signature string, body []byte) bool
+}
+
+// NoopBillingManager performs no billing operations
+type NoopBillingManager struct{}
+
+func (n *NoopBillingManager) CreateTeam(proj *models.Project) (teamID string, err error) {
+	return fmt.Sprintf("%d", proj.ID), nil
+}
+
+func (n *NoopBillingManager) DeleteTeam(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) 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(projectID uint, user *models.User) (token string, err error) {
+	return "", nil
+}
+
+func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error) {
+	return nil, nil
+}
+
+func (n *NoopBillingManager) VerifySignature(signature string, body []byte) bool {
+	return false
+}

+ 28 - 0
internal/kubernetes/nodes/nodes.go

@@ -10,6 +10,34 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+type TotalAllocatable struct {
+	CPU    uint
+	Memory uint
+}
+
+func GetAllocatableResources(clientset kubernetes.Interface) (*TotalAllocatable, error) {
+	nodeList, err := clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	var totCPU uint64 = 0
+	var totMem uint64 = 0
+
+	for _, node := range nodeList.Items {
+		capac := node.Status.Allocatable
+
+		totCPU += uint64(capac.Cpu().MilliValue())
+		totMem += capac.Memory().AsDec().UnscaledBig().Uint64()
+	}
+
+	return &TotalAllocatable{
+		CPU:    uint(totCPU),
+		Memory: uint(totMem),
+	}, nil
+}
+
 type NodeUsage struct {
 	cpuReqs                        string
 	memoryReqs                     string

+ 12 - 0
internal/models/project.go

@@ -7,6 +7,15 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
+type ProjectPlan string
+
+const (
+	ProjectPlanBasic      ProjectPlan = "basic"
+	ProjectPlanTeam       ProjectPlan = "team"
+	ProjectPlanGrowth     ProjectPlan = "growth"
+	ProjectPlanEnterprise ProjectPlan = "enterprise"
+)
+
 // Project type that extends gorm.Model
 type Project struct {
 	gorm.Model
@@ -14,6 +23,9 @@ type Project struct {
 	Name  string `json:"name"`
 	Roles []Role `json:"roles"`
 
+	ProjectUsageID      uint
+	ProjectUsageCacheID uint
+
 	// linked repos
 	GitRepos []GitRepo `json:"git_repos,omitempty"`
 

+ 64 - 0
internal/models/usage.go

@@ -0,0 +1,64 @@
+package models
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+// ProjectUsage keeps track of the usage limits for a project
+type ProjectUsage struct {
+	gorm.Model
+
+	// The project ID that this model refers to
+	ProjectID uint
+
+	// The CPU usage, in vCPUs
+	ResourceCPU uint
+
+	// The memory usage, in bytes
+	ResourceMemory uint
+
+	// The number of clusters
+	Clusters uint
+
+	// The number of users
+	Users uint
+}
+
+// ToProjectUsageType converts the project usage model to a project usage API type
+func (p *ProjectUsage) ToProjectUsageType() *types.ProjectUsage {
+	return &types.ProjectUsage{
+		ResourceCPU:    p.ResourceCPU,
+		ResourceMemory: p.ResourceMemory,
+		Clusters:       p.Clusters,
+		Users:          p.Users,
+	}
+}
+
+// ProjectUsageCache stores the latest cache of the resource usage for a project,
+// for fields that are expensive to compute
+type ProjectUsageCache struct {
+	gorm.Model
+
+	// The project ID that this model refers to
+	ProjectID uint
+
+	// The CPU usage, in vCPUs
+	ResourceCPU uint
+
+	// The memory usage, in bytes
+	ResourceMemory uint
+
+	// Whether the user is exceeding usage
+	Exceeded bool
+
+	// How long the user has been exceeding resource limits
+	ExceededSince *time.Time
+}
+
+func (p *ProjectUsageCache) Is1HrOld() bool {
+	timeSince := time.Now().Sub(p.UpdatedAt)
+	return timeSince > 1*time.Hour
+}

+ 2 - 0
internal/repository/gorm/migrate.go

@@ -29,6 +29,8 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.NotificationConfig{},
 		&models.EventContainer{},
 		&models.SubEvent{},
+		&models.ProjectUsage{},
+		&models.ProjectUsageCache{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 6 - 0
internal/repository/gorm/repository.go

@@ -31,6 +31,7 @@ type GormRepository struct {
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
 	event                     repository.EventRepository
+	projectUsage              repository.ProjectUsageRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -133,6 +134,10 @@ func (t *GormRepository) Event() repository.EventRepository {
 	return t.event
 }
 
+func (t *GormRepository) ProjectUsage() repository.ProjectUsageRepository {
+	return t.projectUsage
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte) repository.Repository {
@@ -162,5 +167,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) repository.Repository {
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
 		event:                     NewEventRepository(db),
+		projectUsage:              NewProjectUsageRepository(db),
 	}
 }

+ 87 - 0
internal/repository/gorm/usage.go

@@ -0,0 +1,87 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectUsageRepository implements repository.ProjectUsageRepository
+type ProjectUsageRepository struct {
+	db *gorm.DB
+}
+
+// NewProjectUsageRepository will return errors if canQuery is false
+func NewProjectUsageRepository(db *gorm.DB) repository.ProjectUsageRepository {
+	return &ProjectUsageRepository{db}
+}
+
+// CreateProjectUsage creates a new project usage limit
+func (repo *ProjectUsageRepository) CreateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if err := repo.db.Create(usage).Error; err != nil {
+		return nil, err
+	}
+
+	return usage, nil
+}
+
+// ReadProjectUsage finds the project usage matching a project ID
+func (repo *ProjectUsageRepository) ReadProjectUsage(
+	projID uint,
+) (*models.ProjectUsage, error) {
+	res := &models.ProjectUsage{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(res).Error; err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// UpdateProjectUsage modifies an existing ProjectUsage in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if err := repo.db.Save(usage).Error; err != nil {
+		return nil, err
+	}
+
+	return usage, nil
+}
+
+// CreateProjectUsageCache creates a new project usage cache
+func (repo *ProjectUsageRepository) CreateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if err := repo.db.Create(cache).Error; err != nil {
+		return nil, err
+	}
+
+	return cache, nil
+}
+
+// ReadProjectUsageCache finds the project usage cache matching a project ID
+func (repo *ProjectUsageRepository) ReadProjectUsageCache(
+	projID uint,
+) (*models.ProjectUsageCache, error) {
+	res := &models.ProjectUsageCache{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(res).Error; err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// UpdateProjectUsageCache modifies an existing ProjectUsageCache in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if err := repo.db.Save(cache).Error; err != nil {
+		return nil, err
+	}
+
+	return cache, nil
+}

+ 2 - 1
internal/repository/repository.go

@@ -25,5 +25,6 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
-	Event()                     EventRepository
+	Event() EventRepository
+	ProjectUsage() ProjectUsageRepository
 }

+ 6 - 0
internal/repository/test/repository.go

@@ -30,6 +30,7 @@ type TestRepository struct {
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
 	event                     repository.EventRepository
+	projectUsage              repository.ProjectUsageRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -132,6 +133,10 @@ func (t *TestRepository) Event() repository.EventRepository {
 	return t.event
 }
 
+func (t *TestRepository) ProjectUsage() repository.ProjectUsageRepository {
+	return t.projectUsage
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -161,5 +166,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
 		event:                     NewEventRepository(canQuery),
+		projectUsage:              NewProjectUsageRepository(canQuery),
 	}
 }

+ 129 - 0
internal/repository/test/usage.go

@@ -0,0 +1,129 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectUsageRepository implements repository.ProjectUsageRepository
+type ProjectUsageRepository struct {
+	canQuery bool
+	usages   []*models.ProjectUsage
+	caches   []*models.ProjectUsageCache
+}
+
+// NewProjectUsageRepository will return errors if canQuery is false
+func NewProjectUsageRepository(canQuery bool) repository.ProjectUsageRepository {
+	return &ProjectUsageRepository{
+		canQuery,
+		[]*models.ProjectUsage{},
+		[]*models.ProjectUsageCache{},
+	}
+}
+
+// CreateProjectUsage creates a new project usage limit
+func (repo *ProjectUsageRepository) CreateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if usage == nil {
+		return nil, nil
+	}
+
+	repo.usages = append(repo.usages, usage)
+
+	return usage, nil
+}
+
+// CreateProjectUsage reads a project usage by project id
+func (repo *ProjectUsageRepository) ReadProjectUsage(
+	projID uint,
+) (*models.ProjectUsage, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, pu := range repo.usages {
+		if pu != nil && pu.ProjectID == projID {
+			return pu, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// UpdateProjectUsage modifies an existing ProjectUsage in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(usage.ID-1) >= len(repo.usages) || repo.usages[usage.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(usage.ID - 1)
+	repo.usages[index] = usage
+
+	return usage, nil
+}
+
+// CreateProjectUsageCache creates a new project usage cache
+func (repo *ProjectUsageRepository) CreateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if cache == nil {
+		return nil, nil
+	}
+
+	repo.caches = append(repo.caches, cache)
+
+	return cache, nil
+}
+
+// CreateProjectUsageCache reads a project usage by project id
+func (repo *ProjectUsageRepository) ReadProjectUsageCache(
+	projID uint,
+) (*models.ProjectUsageCache, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, puc := range repo.caches {
+		if puc != nil && puc.ProjectID == projID {
+			return puc, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// UpdateProjectUsageCache modifies an existing ProjectUsageCache in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(cache.ID-1) >= len(repo.caches) || repo.usages[cache.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(cache.ID - 1)
+	repo.caches[index] = cache
+
+	return cache, nil
+}

+ 13 - 0
internal/repository/usage.go

@@ -0,0 +1,13 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// ProjectUsageRepository represents the set of queries on the ProjectUsage model
+type ProjectUsageRepository interface {
+	CreateProjectUsage(usage *models.ProjectUsage) (*models.ProjectUsage, error)
+	ReadProjectUsage(projID uint) (*models.ProjectUsage, error)
+	UpdateProjectUsage(cache *models.ProjectUsage) (*models.ProjectUsage, error)
+	CreateProjectUsageCache(cache *models.ProjectUsageCache) (*models.ProjectUsageCache, error)
+	ReadProjectUsageCache(projID uint) (*models.ProjectUsageCache, error)
+	UpdateProjectUsageCache(cache *models.ProjectUsageCache) (*models.ProjectUsageCache, error)
+}

+ 15 - 0
internal/usage/limit_ce.go

@@ -0,0 +1,15 @@
+// +build !ee
+
+package usage
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+func GetLimit(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error) {
+	copyLimit := types.BasicPlan
+
+	return &copyLimit, nil
+}

+ 16 - 0
internal/usage/limit_ee.go

@@ -0,0 +1,16 @@
+// +build ee
+
+package usage
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/ee/usage"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+var GetLimit func(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error)
+
+func init() {
+	GetLimit = usage.GetLimit
+}

+ 150 - 0
internal/usage/usage.go

@@ -0,0 +1,150 @@
+package usage
+
+import (
+	"errors"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+)
+
+type GetUsageOpts struct {
+	Repo             repository.Repository
+	DOConf           *oauth2.Config
+	Project          *models.Project
+	WhitelistedUsers map[uint]uint
+}
+
+// GetUsage gets a project's current usage and usage limit
+func GetUsage(opts *GetUsageOpts) (
+	current, limit *types.ProjectUsage,
+	resourceUse *models.ProjectUsageCache,
+	err error,
+) {
+	limit, err = GetLimit(opts.Repo, opts.Project)
+
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	// query for the linked cluster counts
+	clusters, err := opts.Repo.Cluster().ListClustersByProjectID(opts.Project.ID)
+
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	// query for the linked user counts
+	roles, err := opts.Repo.Project().ListProjectRoles(opts.Project.ID)
+
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	countedRoles := make([]models.Role, 0)
+
+	for _, role := range roles {
+		if _, exists := opts.WhitelistedUsers[role.UserID]; !exists {
+			countedRoles = append(countedRoles, role)
+		}
+	}
+
+	usageCache, err := opts.Repo.ProjectUsage().ReadProjectUsageCache(opts.Project.ID)
+	isCacheFound := true
+
+	if isCacheFound = !errors.Is(err, gorm.ErrRecordNotFound); err != nil && isCacheFound {
+		return nil, nil, nil, err
+	}
+
+	// if the usage cache is 1 hour old, was not found, or usage is over limit,
+	// re-query for the usage
+	if true || !isCacheFound || usageCache.Is1HrOld() || usageCache.ResourceMemory > limit.ResourceMemory || usageCache.ResourceCPU > limit.ResourceCPU {
+		cpu, memory, err := getResourceUsage(opts, clusters)
+
+		if err != nil {
+			return nil, nil, nil, err
+		}
+
+		if !isCacheFound {
+			usageCache = &models.ProjectUsageCache{
+				ProjectID:      opts.Project.ID,
+				ResourceCPU:    cpu,
+				ResourceMemory: memory,
+			}
+		} else {
+			usageCache.ResourceCPU = cpu
+			usageCache.ResourceMemory = memory
+		}
+
+		isExceeded := isUsageExceeded(usageCache, limit, uint(len(countedRoles)), uint(len(clusters)))
+
+		if !usageCache.Exceeded && isExceeded {
+			// update the usage cache with a time exceeded
+			currTime := time.Now()
+			usageCache.ExceededSince = &currTime
+		}
+
+		usageCache.Exceeded = isExceeded
+
+		if !isCacheFound {
+			usageCache, err = opts.Repo.ProjectUsage().CreateProjectUsageCache(usageCache)
+		} else {
+			usageCache, err = opts.Repo.ProjectUsage().UpdateProjectUsageCache(usageCache)
+		}
+	}
+
+	// we check whether it's currently exceeded based on the cache every time, since
+	// it's an inexpensive operation and involves no further DB lookups
+	usageCache.Exceeded = isUsageExceeded(usageCache, limit, uint(len(countedRoles)), uint(len(clusters)))
+
+	return &types.ProjectUsage{
+		ResourceCPU:    usageCache.ResourceCPU,
+		ResourceMemory: usageCache.ResourceMemory,
+		Clusters:       uint(len(clusters)),
+		Users:          uint(len(countedRoles)),
+	}, limit, usageCache, nil
+}
+
+func isUsageExceeded(usageCache *models.ProjectUsageCache, limit *types.ProjectUsage, numUsers, numClusters uint) bool {
+	isCPUExceeded := limit.ResourceCPU != 0 && usageCache.ResourceCPU > limit.ResourceCPU
+	isMemExceeded := limit.ResourceMemory != 0 && usageCache.ResourceMemory > limit.ResourceMemory
+	isUsersExceeded := limit.Users != 0 && numUsers > limit.Users
+	isClustersExceeded := limit.Clusters != 0 && numClusters > limit.Clusters
+
+	return isCPUExceeded || isMemExceeded || isUsersExceeded || isClustersExceeded
+}
+
+// gets the total resource usage across all nodes in all clusters
+func getResourceUsage(opts *GetUsageOpts, clusters []*models.Cluster) (uint, uint, error) {
+	var totCPU, totMem uint = 0, 0
+
+	for _, cluster := range clusters {
+		ooc := &kubernetes.OutOfClusterConfig{
+			Cluster:           cluster,
+			Repo:              opts.Repo,
+			DigitalOceanOAuth: opts.DOConf,
+		}
+
+		agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+		if err != nil {
+			continue
+		}
+
+		totAlloc, err := nodes.GetAllocatableResources(agent.Clientset)
+
+		if err != nil {
+			continue
+		}
+
+		totCPU += totAlloc.CPU
+		totMem += totAlloc.Memory
+	}
+
+	return totCPU / 1000, totMem / (1000 * 1000), nil
+}

+ 171 - 0
services/usage/usage.go

@@ -0,0 +1,171 @@
+package usage
+
+import (
+	"sync"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/usage"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+
+	rgorm "github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+type UsageTracker struct {
+	db               *gorm.DB
+	repo             repository.Repository
+	doConf           *oauth2.Config
+	whitelistedUsers map[uint]uint
+}
+
+type UsageTrackerOpts struct {
+	DBConf           *env.DBConf
+	DOClientID       string
+	DOClientSecret   string
+	DOScopes         []string
+	ServerURL        string
+	WhitelistedUsers map[uint]uint
+}
+
+const stepSize = 100
+
+func NewUsageTracker(opts *UsageTrackerOpts) (*UsageTracker, error) {
+	db, err := adapter.New(opts.DBConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte(opts.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := rgorm.NewRepository(db, &key)
+
+	doConf := oauth.NewDigitalOceanClient(&oauth.Config{
+		ClientID:     opts.DOClientID,
+		ClientSecret: opts.DOClientSecret,
+		Scopes:       opts.DOScopes,
+		BaseURL:      opts.ServerURL,
+	})
+
+	return &UsageTracker{db, repo, doConf, opts.WhitelistedUsers}, nil
+}
+
+type UsageTrackerResponse struct {
+	CPULimit      uint
+	CPUUsage      uint
+	MemoryLimit   uint
+	MemoryUsage   uint
+	UserLimit     uint
+	UserUsage     uint
+	ClusterLimit  uint
+	ClusterUsage  uint
+	Exceeded      bool
+	ExceededSince time.Time
+	Project       models.Project
+	AdminEmails   []string
+}
+
+func (u *UsageTracker) GetProjectUsage() (map[uint]*UsageTrackerResponse, error) {
+	res := make(map[uint]*UsageTrackerResponse)
+
+	// get the count of the projects
+	var count int64
+
+	if err := u.db.Model(&models.Project{}).Count(&count).Error; err != nil {
+		return nil, err
+	}
+
+	var mu sync.Mutex
+	var wg sync.WaitGroup
+
+	worker := func(project *models.Project) error {
+		defer wg.Done()
+
+		current, limit, cache, err := usage.GetUsage(&usage.GetUsageOpts{
+			Repo:             u.repo,
+			DOConf:           u.doConf,
+			Project:          project,
+			WhitelistedUsers: u.whitelistedUsers,
+		})
+
+		if err != nil {
+			return err
+		}
+
+		// get the admin emails for the project
+		roles, err := u.repo.Project().ListProjectRoles(project.ID)
+
+		if err != nil {
+			return err
+		}
+
+		adminEmails := make([]string, 0)
+
+		for _, role := range roles {
+			if role.Kind == types.RoleAdmin {
+				user, err := u.repo.User().ReadUser(role.UserID)
+
+				if err != nil {
+					continue
+				}
+
+				adminEmails = append(adminEmails, user.Email)
+			}
+		}
+
+		exceededSince := cache.ExceededSince
+
+		if exceededSince == nil {
+			now := time.Now()
+			exceededSince = &now
+		}
+
+		mu.Lock()
+		res[project.ID] = &UsageTrackerResponse{
+			CPUUsage:      cache.ResourceCPU,
+			CPULimit:      limit.ResourceCPU,
+			MemoryUsage:   cache.ResourceMemory,
+			MemoryLimit:   limit.ResourceMemory,
+			UserUsage:     current.Users,
+			UserLimit:     limit.Users,
+			ClusterUsage:  current.Clusters,
+			ClusterLimit:  limit.Clusters,
+			Exceeded:      cache.Exceeded,
+			ExceededSince: *exceededSince,
+			Project:       *project,
+			AdminEmails:   adminEmails,
+		}
+		mu.Unlock()
+
+		return nil
+	}
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		projects := []*models.Project{}
+
+		if err := u.db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&projects).Error; err != nil {
+			return nil, err
+		}
+
+		// go through each project
+		for _, project := range projects {
+			wg.Add(1)
+			go worker(project)
+		}
+
+		wg.Wait()
+	}
+
+	return res, nil
+}