Răsfoiți Sursa

Merge branch 'master' of github.com:porter-dev/porter into nico/implement-registry-listing-on-connect-registry

jnfrati 4 ani în urmă
părinte
comite
a521d4f3e0

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

@@ -34,7 +34,7 @@ jobs:
           cat ./dashboard/.env
           cat ./dashboard/.env
       - name: Build
       - name: Build
         run: |
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./docker/Dockerfile --build-arg version=${{steps.tag_name.outputs.tag}}
+          DOCKER_BUILDKIT=1 docker build . -t porter1/porter:${{steps.tag_name.outputs.tag}} -f ./ee/docker/ee.Dockerfile --build-arg version=${{steps.tag_name.outputs.tag}}
       - name: Push
       - name: Push
         run: |
         run: |
           docker push porter1/porter:${{steps.tag_name.outputs.tag}}
           docker push porter1/porter:${{steps.tag_name.outputs.tag}}
@@ -76,7 +76,7 @@ jobs:
         run: |
         run: |
           go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
           go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
-          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./portersvr ./cmd/app/ &
+          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./portersvr ./cmd/app/ &
           wait
           wait
         env:
         env:
           GOOS: linux
           GOOS: linux

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

@@ -34,3 +34,15 @@ func NewBillingWebhookHandler(
 ) http.Handler {
 ) http.Handler {
 	return handlers.NewUnavailable(config, "billing_webhook")
 	return handlers.NewUnavailable(config, "billing_webhook")
 }
 }
+
+type BillingAddProjectHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewBillingAddProjectHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return handlers.NewUnavailable(config, "billing_add_project")
+}

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

@@ -22,7 +22,13 @@ var NewBillingWebhookHandler func(
 	decoderValidator shared.RequestDecoderValidator,
 	decoderValidator shared.RequestDecoderValidator,
 ) http.Handler
 ) http.Handler
 
 
+var NewBillingAddProjectHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler
+
 func init() {
 func init() {
 	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
 	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
 	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
 	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
+	NewBillingAddProjectHandler = billing.NewBillingAddProjectHandler
 }
 }

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

@@ -2,6 +2,7 @@ package router
 
 
 import (
 import (
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
@@ -511,5 +512,29 @@ func GetBaseRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// POST /api/internal/billing -> billing.NewBillingAddProjectHandler
+	addProjectBillingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/internal/billing",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	addProjectBillingHandler := billing.NewBillingAddProjectHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: addProjectBillingEndpoint,
+		Handler:  addProjectBillingHandler,
+		Router:   r,
+	})
+
 	return routes
 	return routes
 }
 }

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

@@ -81,6 +81,9 @@ type ServerConf struct {
 	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
 	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
 
 
 	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
 	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
+
+	// Token for internal retool to authenticate to internal API endpoints
+	RetoolToken string `env:"RETOOL_TOKEN"`
 }
 }
 
 
 // DBConf is the database configuration: if generated from environment variables,
 // DBConf is the database configuration: if generated from environment variables,

+ 15 - 0
api/types/billing.go

@@ -0,0 +1,15 @@
+package types
+
+type AddProjectBillingRequest struct {
+	ProjectID uint `json:"project_id" form:"required"`
+
+	// Monthly price, in cents
+	Price uint `json:"price" form:"required"`
+
+	Users    uint `json:"users"`
+	Clusters uint `json:"clusters"`
+	CPU      uint `json:"cpu"`
+	Memory   uint `json:"memory"`
+
+	ExistingPlanName string `json:"existing_plan_name"`
+}

+ 17 - 45
cli/cmd/docker/agent.go

@@ -261,60 +261,32 @@ func GetServerURLFromTag(image string) (string, error) {
 
 
 	domain := reference.Domain(named)
 	domain := reference.Domain(named)
 
 
-	// if domain name is empty, use index.docker.io/v1
 	if domain == "" {
 	if domain == "" {
+		// if domain name is empty, use index.docker.io/v1
 		return "index.docker.io/v1", nil
 		return "index.docker.io/v1", nil
+	} else if matches := ecrPattern.FindStringSubmatch(image); len(matches) >= 3 {
+		// if this matches ECR, just use the domain name
+		return domain, nil
+	} else if strings.Contains(image, "gcr.io") || strings.Contains(image, "registry.digitalocean.com") {
+		// if this matches GCR or DOCR, use the first path component
+		return fmt.Sprintf("%s/%s", domain, strings.Split(reference.Path(named), "/")[0]), nil
 	}
 	}
 
 
-	return domain, nil
+	// otherwise, best-guess is to get components of path that aren't the image name
+	pathParts := strings.Split(reference.Path(named), "/")
+	nonImagePath := ""
 
 
-	// else if matches := ecrPattern.FindStringSubmatch(image); matches >= 3 {
-	// 	// if this matches ECR, just use the domain name
-	// 	return domain, nil
-	// } else if strings.Contains(image, "gcr.io") || strings.Contains(image, "registry.digitalocean.com") {
-	// 	// if this matches GCR or DOCR, use the first path component
-	// 	return fmt.Sprintf("%s/%s", domain, strings.Split(path, "/")[0]), nil
-	// }
-
-	// // otherwise, best-guess is to get components of path that aren't the image name
-	// pathParts := strings.Split(path, "/")
-	// nonImagePath := ""
-
-	// if len(pathParts) > 1 {
-	// 	nonImagePath = strings.Join(pathParts[0:len(pathParts)-1], "/")
-	// }
+	if len(pathParts) > 1 {
+		nonImagePath = strings.Join(pathParts[0:len(pathParts)-1], "/")
+	}
 
 
-	// if err != nil {
-	// 	return "", err
-	// }
+	if err != nil {
+		return "", err
+	}
 
 
-	// return fmt.Sprintf("%s/%s", domain, nonImagePath), nil
+	return fmt.Sprintf("%s/%s", domain, nonImagePath), nil
 }
 }
 
 
-// func imagePush(dockerClient *client.Client) error {
-// 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*120)
-// 	defer cancel()
-
-// 	authConfigBytes, _ := json.Marshal(authConfig)
-// 	authConfigEncoded := base64.URLEncoding.EncodeToString(authConfigBytes)
-
-// 	tag := dockerRegistryUserID + "/node-hello"
-// 	opts := types.ImagePushOptions{RegistryAuth: authConfigEncoded}
-// 	rd, err := dockerClient.ImagePush(ctx, tag, opts)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	defer rd.Close()
-
-// 	err = print(rd)
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	return nil
-// }
-
 // WaitForContainerStop waits until a container has stopped to exit
 // WaitForContainerStop waits until a container has stopped to exit
 func (a *Agent) WaitForContainerStop(id string) error {
 func (a *Agent) WaitForContainerStop(id string) error {
 	// wait for container to stop before exit
 	// wait for container to stop before exit

+ 1 - 1
dashboard/docker/dev.Dockerfile

@@ -1,6 +1,6 @@
 # Development environment
 # Development environment
 # -----------------------
 # -----------------------
-FROM node:latest
+FROM node:lts
 WORKDIR /webpack
 WORKDIR /webpack
 
 
 COPY package*.json ./
 COPY package*.json ./

+ 1 - 1
dashboard/src/main/home/onboarding/components/RegistryImageList.tsx

@@ -41,7 +41,7 @@ const RegistryImageList: React.FC<{
         integrationList[registryType] && integrationList[registryType].icon
         integrationList[registryType] && integrationList[registryType].icon
       );
       );
     } else {
     } else {
-      return integrationList["docker"].icon;
+      return integrationList["dockerhub"].icon;
     }
     }
   };
   };
 
 

+ 1 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx

@@ -324,6 +324,7 @@ export const TestRegistryConnection: React.FC<{
       <RegistryImageList
       <RegistryImageList
         project={snap.project}
         project={snap.project}
         registry_id={snap.connected_registry.settings.registry_connection_id}
         registry_id={snap.connected_registry.settings.registry_connection_id}
+        registryType={"gcr"}
       />
       />
       <SaveButton
       <SaveButton
         text="Continue"
         text="Continue"

+ 4 - 0
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -49,6 +49,10 @@ const ProvisionResources: React.FC<Props> = () => {
   };
   };
 
 
   const renderSaveButton = () => {
   const renderSaveButton = () => {
+    if (typeof infraStatus?.hasError !== "boolean") {
+      return;
+    }
+
     if (infraStatus && !infraStatus.hasError) {
     if (infraStatus && !infraStatus.hasError) {
       return (
       return (
         <>
         <>

+ 13 - 2
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx

@@ -20,6 +20,7 @@ export const SharedStatus: React.FC<{
   } = useWebsockets();
   } = useWebsockets();
 
 
   const [tfModules, setTFModules] = useState<TFModule[]>([]);
   const [tfModules, setTFModules] = useState<TFModule[]>([]);
+  const [isLoadingState, setIsLoadingState] = useState(true);
 
 
   const updateTFModules = (
   const updateTFModules = (
     index: number,
     index: number,
@@ -86,6 +87,9 @@ export const SharedStatus: React.FC<{
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {
+    if (isLoadingState) {
+      return;
+    }
     // recompute tf module state each time, to see if infra is ready
     // recompute tf module state each time, to see if infra is ready
     if (tfModules.length > 0) {
     if (tfModules.length > 0) {
       // see if all tf modules are in a "created" state
       // see if all tf modules are in a "created" state
@@ -158,7 +162,7 @@ export const SharedStatus: React.FC<{
     } else {
     } else {
       setInfraStatus(null);
       setInfraStatus(null);
     }
     }
-  }, [tfModules]);
+  }, [tfModules, isLoadingState]);
 
 
   const setupInfraWebsocket = (
   const setupInfraWebsocket = (
     websocketID: string,
     websocketID: string,
@@ -257,6 +261,7 @@ export const SharedStatus: React.FC<{
   };
   };
 
 
   const updateDesiredState = (index: number, val: TFModule) => {
   const updateDesiredState = (index: number, val: TFModule) => {
+    setIsLoadingState(true);
     api
     api
       .getInfraDesired(
       .getInfraDesired(
         "<token>",
         "<token>",
@@ -289,9 +294,15 @@ export const SharedStatus: React.FC<{
 
 
             // merge with empty current map
             // merge with empty current map
             mergeCurrentAndDesired(index, desired, currentMap);
             mergeCurrentAndDesired(index, desired, currentMap);
+          })
+          .finally(() => {
+            setIsLoadingState(true);
           });
           });
       })
       })
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        console.log(err);
+        setIsLoadingState(true);
+      });
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {

+ 4 - 1
dashboard/webpack.config.js

@@ -11,7 +11,10 @@ const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
 const TerserPlugin = require("terser-webpack-plugin");
 const TerserPlugin = require("terser-webpack-plugin");
 
 
 module.exports = () => {
 module.exports = () => {
-  const env = dotenv.config().parsed;
+  let env = dotenv.config().parsed;
+  if (!env) {
+    env = process.env;
+  }
   const envKeys = Object.keys(env).reduce((prev, next) => {
   const envKeys = Object.keys(env).reduce((prev, next) => {
     prev[`process.env.${next}`] = JSON.stringify(env[next]);
     prev[`process.env.${next}`] = JSON.stringify(env[next]);
     return prev;
     return prev;

+ 1 - 1
docker/Dockerfile

@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 
 # Webpack build environment
 # Webpack build environment
 # -------------------------
 # -------------------------
-FROM node:latest as build-webpack
+FROM node:lts as build-webpack
 WORKDIR /webpack
 WORKDIR /webpack
 
 
 COPY ./dashboard ./
 COPY ./dashboard ./

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

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

+ 214 - 18
ee/billing/ironplans.go

@@ -32,7 +32,8 @@ type Client struct {
 
 
 	httpClient *http.Client
 	httpClient *http.Client
 
 
-	defaultPlan *Plan
+	defaultPlanID string
+	customPlanID  string
 }
 }
 
 
 // NewClient creates a new billing API client
 // NewClient creates a new billing API client
@@ -41,23 +42,24 @@ func NewClient(serverURL, apiKey string, repo repository.EERepository) (*Client,
 		Timeout: time.Minute,
 		Timeout: time.Minute,
 	}
 	}
 
 
-	client := &Client{apiKey, serverURL, repo, httpClient, nil}
+	client := &Client{apiKey, serverURL, repo, httpClient, "", ""}
 
 
 	// get the default plans from the IronPlans API server
 	// get the default plans from the IronPlans API server
-	listResp := &ListPlansResponse{}
-	err := client.getRequest("/plans/v1", listResp)
+	defPlanID, err := client.GetExistingPublicPlan("Free")
 
 
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	for _, plan := range listResp.Results {
-		if plan.Name == "Free" {
-			copyPlan := plan
-			client.defaultPlan = &copyPlan
-		}
+	customPlanID, err := client.GetExistingPublicPlan("Enterprise")
+
+	if err != nil {
+		return nil, err
 	}
 	}
 
 
+	client.defaultPlanID = defPlanID
+	client.customPlanID = customPlanID
+
 	return client, nil
 	return client, nil
 }
 }
 
 
@@ -72,13 +74,8 @@ func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
 	}
 	}
 
 
 	// put the user on the free plan, as the default behavior, if there is a default plan
 	// 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 c.defaultPlanID != "" {
+		err = c.CreateOrUpdateSubscription(resp.ID, c.defaultPlanID)
 
 
 		if err != nil {
 		if err != nil {
 			return "", fmt.Errorf("subscription creation failed: %s", err)
 			return "", fmt.Errorf("subscription creation failed: %s", err)
@@ -117,7 +114,197 @@ func (c *Client) GetTeamID(proj *cemodels.Project) (teamID string, err error) {
 	return projBilling.BillingTeamID, nil
 	return projBilling.BillingTeamID, nil
 }
 }
 
 
+func (c *Client) CreatePlan(teamID string, proj *cemodels.Project, planSpec *types.AddProjectBillingRequest) (string, error) {
+	// construct basic plan object
+	planFeatures := make([]*CreatePlanFeature, 0)
+
+	userDisplay := fmt.Sprintf("Up to %d users", planSpec.Users)
+
+	if planSpec.Users == 0 {
+		userDisplay = fmt.Sprintf("Unlimited users")
+	}
+
+	clusterDisplay := fmt.Sprintf("Up to %d clusters", planSpec.Clusters)
+
+	if planSpec.Clusters == 0 {
+		clusterDisplay = fmt.Sprintf("Unlimited clusters")
+	}
+
+	cpuDisplay := fmt.Sprintf("Up to %d CPUs", planSpec.CPU)
+
+	if planSpec.CPU == 0 {
+		cpuDisplay = fmt.Sprintf("Unlimited CPU")
+	}
+
+	ramDisplay := fmt.Sprintf("Up to %d GB RAM", planSpec.Memory)
+
+	if planSpec.Memory == 0 {
+		ramDisplay = fmt.Sprintf("Unlimited RAM")
+	}
+
+	planFeatures = append(planFeatures, &CreatePlanFeature{
+		Display: userDisplay,
+	})
+	planFeatures = append(planFeatures, &CreatePlanFeature{
+		Display: clusterDisplay,
+	})
+	planFeatures = append(planFeatures, &CreatePlanFeature{
+		Display: cpuDisplay,
+	})
+	planFeatures = append(planFeatures, &CreatePlanFeature{
+		Display: ramDisplay,
+	})
+
+	var customPlanID *string
+
+	if c.customPlanID != "" {
+		customPlanID = &c.customPlanID
+	}
+
+	createPlanReq := &CreatePlanRequest{
+		Name:               proj.Name,
+		IsActive:           true,
+		IsPublic:           false,
+		IsTrialAllowed:     true,
+		ReplacePlanID:      customPlanID,
+		PerMonthPriceCents: planSpec.Price,
+		PerYearPriceCents:  12 * planSpec.Price,
+		Features:           planFeatures,
+		TeamsAccess: []*CreatePlanTeamsAccess{
+			{
+				TeamID: teamID,
+				Revoke: false,
+			},
+		},
+	}
+
+	// find all relevant feature IDs
+	listResp := &ListFeaturesResponse{}
+	err := c.getRequest("/features/v1", listResp)
+
+	if err != nil {
+		return "", err
+	}
+
+	// create a feature spec per feature ID, and add to features array for plan
+	for _, feature := range listResp.Results {
+		featureSpec := &CreateFeatureSpecRequest{
+			Name:         "unnamed",
+			RecordPeriod: "monthly",
+			Aggregation:  "sum",
+			UnitPrice:    0,
+		}
+
+		switch feature.Slug {
+		case FeatureSlugUsers:
+			featureSpec.MaxLimit = planSpec.Users
+			featureSpec.UnitsIncluded = planSpec.Users
+		case FeatureSlugClusters:
+			featureSpec.MaxLimit = planSpec.Clusters
+			featureSpec.UnitsIncluded = planSpec.Clusters
+		case FeatureSlugCPU:
+			featureSpec.MaxLimit = planSpec.CPU
+			featureSpec.UnitsIncluded = planSpec.CPU
+		case FeatureSlugMemory:
+			featureSpec.MaxLimit = planSpec.Memory
+			featureSpec.UnitsIncluded = planSpec.Memory
+		// continue on default behavior so that feature spec is not created for
+		// features that don't match a slug
+		default:
+			continue
+		}
+
+		// create the feature spec
+		resp := &CreateFeaturespecResponse{}
+		err = c.postRequest("/featurespecs/v1/", featureSpec, resp)
+
+		if err != nil {
+			return "", err
+		}
+
+		var index int
+		switch feature.Slug {
+		case FeatureSlugUsers:
+			index = 0
+		case FeatureSlugClusters:
+			index = 1
+		case FeatureSlugCPU:
+			index = 2
+		case FeatureSlugMemory:
+			index = 3
+		}
+
+		createPlanReq.Features[index].FeatureID = feature.ID
+		createPlanReq.Features[index].SpecID = resp.ID
+	}
+
+	// create the plan and return the plan ID
+	planResp := &Plan{}
+
+	err = c.postRequest("/plans/v1/", createPlanReq, planResp)
+
+	if err != nil {
+		return "", err
+	}
+
+	return planResp.ID, nil
+}
+
+func (c *Client) CreateOrUpdateSubscription(teamID, planID string) error {
+	// determine if subscription already exists by reading the team ID and seeing if the subscription
+	// field has an ID attached
+	teamResp := &Team{}
+	err := c.getRequest(fmt.Sprintf("/teams/v1/%s", teamID), teamResp)
+
+	if err != nil {
+		return err
+	}
+
+	subReq := &CreateSubscriptionRequest{
+		PlanID:     planID,
+		NextPlanID: c.defaultPlanID,
+		TeamID:     teamID,
+		IsPaused:   false,
+	}
+
+	// if subscription ID is not empty, perform a PUT request to update the subscription
+	if teamResp.Subscription.ID != "" {
+		// delete the subscription
+		err = c.deleteRequest(fmt.Sprintf("/subscriptions/v1/%s/purge/", teamResp.Subscription.ID), nil, nil)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return c.postRequest("/subscriptions/v1", subReq, nil)
+}
+
+func (c *Client) GetExistingPublicPlan(planName string) (string, error) {
+	listResp := &ListPlansResponse{}
+	err := c.getRequest("/plans/v1/", listResp, map[string]string{"is_public": "true"})
+
+	if err != nil {
+		return "", err
+	}
+
+	for _, plan := range listResp.Results {
+		if plan.Name == planName {
+			return plan.ID, nil
+		}
+	}
+
+	return "", fmt.Errorf("plan not found")
+}
+
 func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
 func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
+	// determine if user is already in team/has user billing
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, user.ID)
+
+	if userBilling != nil {
+		return nil
+	}
+
 	roleEnum := RoleEnumMember
 	roleEnum := RoleEnumMember
 
 
 	// if user's role is admin, add them to the team as an owner
 	// if user's role is admin, add them to the team as an owner
@@ -134,7 +321,7 @@ func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodel
 
 
 	resp := &Teammate{}
 	resp := &Teammate{}
 
 
-	err := c.postRequest("/team_memberships/v1", req, resp)
+	err = c.postRequest("/team_memberships/v1", req, resp)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -292,7 +479,7 @@ func (c *Client) deleteRequest(path string, data interface{}, dst interface{}) e
 	return c.writeRequest("DELETE", path, data, dst)
 	return c.writeRequest("DELETE", path, data, dst)
 }
 }
 
 
-func (c *Client) getRequest(path string, dst interface{}) error {
+func (c *Client) getRequest(path string, dst interface{}, query ...map[string]string) error {
 	reqURL, err := url.Parse(c.serverURL)
 	reqURL, err := url.Parse(c.serverURL)
 
 
 	if err != nil {
 	if err != nil {
@@ -301,6 +488,15 @@ func (c *Client) getRequest(path string, dst interface{}) error {
 
 
 	reqURL.Path = path
 	reqURL.Path = path
 
 
+	q := reqURL.Query()
+	for _, queryGroup := range query {
+		for key, val := range queryGroup {
+			q.Add(key, val)
+		}
+	}
+
+	reqURL.RawQuery = q.Encode()
+
 	req, err := http.NewRequest(
 	req, err := http.NewRequest(
 		"GET",
 		"GET",
 		reqURL.String(),
 		reqURL.String(),

+ 44 - 0
ee/billing/types.go

@@ -38,6 +38,49 @@ type Plan struct {
 	Features   []PlanFeature `json:"features"`
 	Features   []PlanFeature `json:"features"`
 }
 }
 
 
+type CreatePlanRequest struct {
+	Name               string                   `json:"name"`
+	IsActive           bool                     `json:"is_active"`
+	IsPublic           bool                     `json:"is_public"`
+	IsTrialAllowed     bool                     `json:"is_trial_allowed"`
+	PerMonthPriceCents uint                     `json:"per_month_price_cents"`
+	PerYearPriceCents  uint                     `json:"per_year_price_cents"`
+	ReplacePlanID      *string                  `json:"replace_plan_id"`
+	Features           []*CreatePlanFeature     `json:"features"`
+	TeamsAccess        []*CreatePlanTeamsAccess `json:"teams_access"`
+}
+
+type CreatePlanFeature struct {
+	FeatureID string `json:"feature_id"`
+	SpecID    string `json:"spec_id"`
+	Display   string `json:"display"`
+	Sort      uint   `json:"sort"`
+	IsActive  bool   `json:"is_active"`
+}
+
+type CreatePlanTeamsAccess struct {
+	TeamID string `json:"team_id"`
+	Revoke bool   `json:"revoke"`
+}
+
+type CreateFeatureSpecRequest struct {
+	Name          string `json:"name"`
+	RecordPeriod  string `json:"record_period"`
+	Aggregation   string `json:"aggregation"`
+	MaxLimit      uint   `json:"max_limit"`
+	UnitPrice     uint   `json:"unit_price"`
+	UnitsIncluded uint   `json:"units_included"`
+}
+
+type CreateFeaturespecResponse struct {
+	*CreateFeatureSpecRequest
+	ID string `json:"id"`
+}
+
+type ListFeaturesResponse struct {
+	Results []Feature `json:"results"`
+}
+
 type ListPlansResponse struct {
 type ListPlansResponse struct {
 	Results []Plan `json:"results"`
 	Results []Plan `json:"results"`
 }
 }
@@ -50,6 +93,7 @@ type PlanFeature struct {
 }
 }
 
 
 type Feature struct {
 type Feature struct {
+	ID   string `json:"id"`
 	Slug string `json:"slug"`
 	Slug string `json:"slug"`
 }
 }
 
 

+ 1 - 1
ee/docker/ee.Dockerfile

@@ -38,7 +38,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 
 # Webpack build environment
 # Webpack build environment
 # -------------------------
 # -------------------------
-FROM node:latest as build-webpack
+FROM node:lts as build-webpack
 WORKDIR /webpack
 WORKDIR /webpack
 
 
 COPY ./dashboard ./
 COPY ./dashboard ./

+ 22 - 0
internal/billing/billing.go

@@ -3,6 +3,7 @@ package billing
 import (
 import (
 	"fmt"
 	"fmt"
 
 
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 )
 )
 
 
@@ -19,6 +20,15 @@ type BillingManager interface {
 	// GetTeamID gets the billing team id for a project
 	// GetTeamID gets the billing team id for a project
 	GetTeamID(proj *models.Project) (teamID string, err error)
 	GetTeamID(proj *models.Project) (teamID string, err error)
 
 
+	// CreatePlan creates a new plan based on the requested limits
+	CreatePlan(teamID string, proj *models.Project, planSpec *types.AddProjectBillingRequest) (string, error)
+
+	// CreateOrUpdateSubscription creates or updates a new subscription to a plan, based on a team and plan ID
+	CreateOrUpdateSubscription(teamID, planID string) error
+
+	// GetExistingPublicPlan returns an existing public plan based on a name
+	GetExistingPublicPlan(planName string) (string, error)
+
 	// AddUserToTeam adds a user to a team, and cases on whether the user can view
 	// AddUserToTeam adds a user to a team, and cases on whether the user can view
 	// billing based on the role.
 	// billing based on the role.
 	AddUserToTeam(teamID string, user *models.User, role *models.Role) error
 	AddUserToTeam(teamID string, user *models.User, role *models.Role) error
@@ -57,6 +67,18 @@ func (n *NoopBillingManager) GetTeamID(proj *models.Project) (teamID string, err
 	return fmt.Sprintf("%d", proj.ID), nil
 	return fmt.Sprintf("%d", proj.ID), nil
 }
 }
 
 
+func (n *NoopBillingManager) CreatePlan(teamID string, proj *models.Project, planSpec *types.AddProjectBillingRequest) (string, error) {
+	return "", nil
+}
+
+func (n *NoopBillingManager) CreateOrUpdateSubscription(teamID, planID string) error {
+	return nil
+}
+
+func (n *NoopBillingManager) GetExistingPublicPlan(planName string) (string, error) {
+	return "", nil
+}
+
 func (n *NoopBillingManager) AddUserToTeam(teamID string, user *models.User, role *models.Role) error {
 func (n *NoopBillingManager) AddUserToTeam(teamID string, user *models.User, role *models.Role) error {
 	return nil
 	return nil
 }
 }

+ 1 - 1
internal/models/integrations/aws.go

@@ -135,7 +135,7 @@ func (a *AWSIntegration) GetBearerToken(
 
 
 	tok, err := generator.GetWithOptions(&token.GetTokenOptions{
 	tok, err := generator.GetWithOptions(&token.GetTokenOptions{
 		Session:   sess,
 		Session:   sess,
-		ClusterID: clusterID,
+		ClusterID: clusterIDGuess,
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {

+ 1 - 1
scripts/build/osx.sh

@@ -4,7 +4,7 @@
 
 
 go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter ./cli &
 go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter ./cli &
 go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
 go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
-go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./portersvr ./cmd/app/ &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -tags ee -o ./portersvr ./cmd/app/ &
 wait
 wait
 
 
 mkdir -p /release/darwin
 mkdir -p /release/darwin

+ 1 - 1
scripts/build/win.sh

@@ -4,7 +4,7 @@
 
 
 go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter.exe ./cli &
 go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter.exe ./cli &
 go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter.exe ./cmd/docker-credential-porter/ &
 go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter.exe ./cmd/docker-credential-porter/ &
-go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./portersvr.exe ./cmd/app/ &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -tags ee -o ./portersvr.exe ./cmd/app/ &
 wait
 wait
 
 
 mkdir -p /release/windows
 mkdir -p /release/windows

+ 1 - 1
services/job_sidecar_container/Dockerfile

@@ -5,6 +5,6 @@ RUN apk --no-cache add procps coreutils
 
 
 COPY *.sh .
 COPY *.sh .
 
 
-RUN ["chmod", "+x", "./job_killer.sh", "./signal.sh"]
+RUN ["chmod", "+x", "./job_killer.sh", "./signal.sh", "./sidecar_killer.sh"]
 
 
 ENTRYPOINT ["./job_killer.sh"]
 ENTRYPOINT ["./job_killer.sh"]

+ 9 - 1
services/job_sidecar_container/job_killer.sh

@@ -1,6 +1,6 @@
 #!/bin/sh
 #!/bin/sh
 
 
-# Usage: job_killer.sh [-c]? [grace_period_seconds] [process_pattern]
+# Usage: job_killer.sh [-c]? [grace_period_seconds] [process_pattern] [sidecar]?
 #
 #
 # This script waits for a termination signal and gracefully terminates another process before exiting. 
 # This script waits for a termination signal and gracefully terminates another process before exiting. 
 # 
 # 
@@ -24,9 +24,11 @@ if $kill_child_procs
 then
 then
   grace_period_seconds=$2
   grace_period_seconds=$2
   target=$3
   target=$3
+  sidecar=$4
 else
 else
   grace_period_seconds=$1
   grace_period_seconds=$1
   target=$2
   target=$2
+  sidecar=$3
 fi  
 fi  
 
 
 pattern="$(printf '[%s]%s' $(echo $target | cut -c 1) $(echo $target | cut -c 2-))"
 pattern="$(printf '[%s]%s' $(echo $target | cut -c 1) $(echo $target | cut -c 2-))"
@@ -87,4 +89,10 @@ if [ -n "$target_pid" ]; then
     child=$!
     child=$!
 
 
     wait "$child"
     wait "$child"
+fi
+
+# run the sidecar killer, this will terminate any additional sidecars if necessary
+if [ -n "$sidecar" ]; then
+    echo "killing sidecar command: $sidecar"
+    ./sidecar_killer.sh $sidecar
 fi
 fi

+ 11 - 0
services/job_sidecar_container/sidecar_killer.sh

@@ -0,0 +1,11 @@
+#!/bin/sh
+
+# Sends termination signal to other sidecar pods, meant to run as a pre-stop hook
+# or called by ./job_killer.sh.
+# 
+# Usage: ./sidecar_killer.sh [target_process]
+
+target=$1
+pattern="$(printf '[%s]%s' $(echo $target | cut -c 1) $(echo $target | cut -c 2-))"
+pid=$(ps x | grep -v './sidecar_killer.sh' | grep "$pattern" | awk '{ printf "%d ", $1 }'); 
+kill -TERM $pid