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

Merge pull request #1400 from porter-dev/staging

Internal tooling + onboarding cleanup -> production
abelanger5 4 лет назад
Родитель
Сommit
ae8854a566
31 измененных файлов с 690 добавлено и 296 удалено
  1. 2 2
      .github/workflows/release.yaml
  2. 12 0
      api/server/handlers/billing/billing_ce.go
  3. 6 0
      api/server/handlers/billing/billing_ee.go
  4. 25 0
      api/server/router/base.go
  5. 3 0
      api/server/shared/config/env/envconfs.go
  6. 15 0
      api/types/billing.go
  7. 17 45
      cli/cmd/docker/agent.go
  8. 1 1
      dashboard/docker/dev.Dockerfile
  9. 4 5
      dashboard/src/main/home/onboarding/Routes.tsx
  10. 1 1
      dashboard/src/main/home/onboarding/components/RegistryImageList.tsx
  11. 28 73
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx
  12. 0 25
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistryWrapper.tsx
  13. 24 22
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx
  14. 1 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx
  15. 39 47
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  16. 0 30
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResourcesWrapper.tsx
  17. 26 17
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx
  18. 13 2
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/SharedStatus.tsx
  19. 4 1
      dashboard/webpack.config.js
  20. 1 1
      docker/Dockerfile
  21. 163 0
      ee/api/server/handlers/billing/add_project.go
  22. 214 18
      ee/billing/ironplans.go
  23. 44 0
      ee/billing/types.go
  24. 1 1
      ee/docker/ee.Dockerfile
  25. 22 0
      internal/billing/billing.go
  26. 1 1
      internal/models/integrations/aws.go
  27. 1 1
      scripts/build/osx.sh
  28. 1 1
      scripts/build/win.sh
  29. 1 1
      services/job_sidecar_container/Dockerfile
  30. 9 1
      services/job_sidecar_container/job_killer.sh
  31. 11 0
      services/job_sidecar_container/sidecar_killer.sh

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

@@ -34,7 +34,7 @@ jobs:
           cat ./dashboard/.env
       - name: Build
         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
         run: |
           docker push porter1/porter:${{steps.tag_name.outputs.tag}}
@@ -76,7 +76,7 @@ jobs:
         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 '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
         env:
           GOOS: linux

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

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

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

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

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

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

@@ -81,6 +81,9 @@ type ServerConf struct {
 	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
 
 	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,

+ 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)
 
-	// if domain name is empty, use index.docker.io/v1
 	if domain == "" {
+		// if domain name is empty, use index.docker.io/v1
 		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
 func (a *Agent) WaitForContainerStop(id string) error {
 	// wait for container to stop before exit

+ 1 - 1
dashboard/docker/dev.Dockerfile

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

+ 4 - 5
dashboard/src/main/home/onboarding/Routes.tsx

@@ -1,10 +1,9 @@
 import React from "react";
 import { Route, Switch } from "react-router";
-import { Redirect } from "react-router-dom";
 import { OFState } from "./state";
-import ConnectRegistryWrapper from "./steps/ConnectRegistry/ConnectRegistryWrapper";
+import ConnectRegistry from "./steps/ConnectRegistry/ConnectRegistry";
 import ConnectSource from "./steps/ConnectSource";
-import ProvisionResourcesWrapper from "./steps/ProvisionResources/ProvisionResourcesWrapper";
+import ProvisionResources from "./steps/ProvisionResources/ProvisionResources";
 
 export const Routes = () => {
   return (
@@ -16,10 +15,10 @@ export const Routes = () => {
           />
         </Route>
         <Route path={["/onboarding/registry/:step?"]}>
-          <ConnectRegistryWrapper />
+          <ConnectRegistry />
         </Route>
         <Route path={[`/onboarding/provision/:step?`]}>
-          <ProvisionResourcesWrapper />
+          <ProvisionResources />
         </Route>
       </Switch>
     </>

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

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

+ 28 - 73
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistry.tsx

@@ -8,43 +8,39 @@ import styled from "styled-components";
 import ProviderSelector, {
   registryOptions,
 } from "../../components/ProviderSelector";
-import { SupportedProviders } from "../../types";
 import backArrow from "assets/back_arrow.png";
 
 import FormFlowWrapper from "./forms/FormFlow";
+import { OFState } from "../../state";
+import { useSnapshot } from "valtio";
 
-const ConnectRegistry: React.FC<{
-  provider: SupportedProviders;
-  enable_go_back: boolean;
-  project: {
-    id: number;
-    name: string;
-  };
-  onSelectProvider: (provider: SupportedProviders | "skip") => void;
-  onSaveCredentials: (credentials: any) => void;
-  onSaveSettings: (settings: any) => void;
-  onSuccess: () => void;
-  onSkip: () => void;
-  goBack: () => void;
-}> = ({
-  onSelectProvider,
-  onSaveCredentials,
-  onSaveSettings,
-  onSuccess,
-  onSkip,
-  project,
-  provider,
-  enable_go_back,
-  goBack,
-}) => {
+const ConnectRegistry: React.FC<{}> = ({}) => {
+  const snap = useSnapshot(OFState);
   const { step } = useParams<any>();
 
+  const currentProvider = snap.StateHandler.connected_registry?.provider;
+
+  const enableGoBack =
+    snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow;
+
+  const handleGoBack = () => {
+    OFState.actions.nextStep("go_back");
+  };
+
+  const handleSkip = () => {
+    OFState.actions.nextStep("skip");
+  };
+
+  const handleSelectProvider = (provider: string) => {
+    provider !== "skip" && OFState.actions.nextStep("continue", provider);
+  };
+
   return (
     <Div>
-      {enable_go_back && (
+      {enableGoBack && (
         <BackButton
           onClick={() => {
-            goBack();
+            handleGoBack();
           }}
         >
           <BackButtonImg src={backArrow} />
@@ -61,28 +57,19 @@ const ConnectRegistry: React.FC<{
         </a>
       </Subtitle>
       <Helper>
-        {provider
+        {currentProvider
           ? "Link to an existing Docker registry. Don't worry if you don't know what this is."
           : "Link to an existing Docker registry or continue."}
       </Helper>
 
       {step ? (
-        <FormFlowWrapper
-          provider={provider}
-          onSaveCredentials={onSaveCredentials}
-          onSaveSettings={onSaveSettings}
-          onSuccess={onSuccess}
-          project={project}
-          currentStep={step}
-          goBack={goBack}
-          enable_go_back={enable_go_back}
-        />
+        <FormFlowWrapper currentStep={step} />
       ) : (
         <>
           <ProviderSelector
             selectProvider={(provider) => {
               if (provider !== "external") {
-                onSelectProvider(provider);
+                handleSelectProvider(provider);
               }
             }}
             options={registryOptions}
@@ -90,7 +77,7 @@ const ConnectRegistry: React.FC<{
           <NextStep
             text="Continue"
             disabled={false}
-            onClick={() => onSkip()}
+            onClick={() => handleSkip()}
             status={""}
             makeFlush={true}
             clearPosition={true}
@@ -109,45 +96,13 @@ const Div = styled.div`
   width: 100%;
 `;
 
-const FadeWrapper = styled.div<{ delay?: string }>`
-  opacity: 0;
-  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
-  animation-fill-mode: forwards;
-
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const SlideWrapper = styled.div<{ delay?: string }>`
-  opacity: 0;
-  animation: slideIn 0.7s ${(props) => props.delay || "1.3s"};
-  animation-fill-mode: forwards;
-
-  @keyframes slideIn {
-    from {
-      opacity: 0;
-      transform: translateX(30px);
-    }
-    to {
-      opacity: 1;
-      transform: translateX(0);
-    }
-  }
-`;
-
 const Subtitle = styled.div`
   font-size: 16px;
   font-weight: 500;
   margin-top: 16px;
 
   display: flex;
-  align-items; center;
+  align-items: center;
   > a {
     > i {
       font-size: 18px;

+ 0 - 25
dashboard/src/main/home/onboarding/steps/ConnectRegistry/ConnectRegistryWrapper.tsx

@@ -1,25 +0,0 @@
-import React from "react";
-import { useSnapshot } from "valtio";
-import { OFState } from "../../state";
-import ConnectRegistry from "./ConnectRegistry";
-
-const ConnectRegistryWrapper = () => {
-  const snap = useSnapshot(OFState);
-  return (
-    <ConnectRegistry
-      provider={snap.StateHandler.connected_registry?.provider}
-      project={snap.StateHandler.project}
-      onSelectProvider={(provider) => {
-        provider !== "skip" && OFState.actions.nextStep("continue", provider);
-      }}
-      onSaveCredentials={(data) => OFState.actions.nextStep("continue", data)}
-      onSaveSettings={(data) => OFState.actions.nextStep("continue", data)}
-      onSuccess={() => OFState.actions.nextStep("continue")}
-      onSkip={() => OFState.actions.nextStep("skip")}
-      enable_go_back={snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow}
-      goBack={() => OFState.actions.nextStep("go_back")}
-    />
-  );
-};
-
-export default ConnectRegistryWrapper;

+ 24 - 22
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx

@@ -1,4 +1,7 @@
-import { ConnectedRegistryConfig } from "main/home/onboarding/state/StateHandler";
+import {
+  ConnectedRegistryConfig,
+  StateHandler,
+} from "main/home/onboarding/state/StateHandler";
 import Breadcrumb from "components/Breadcrumb";
 import {
   SkipRegistryConnection,
@@ -23,6 +26,8 @@ import {
   SettingsForm as GCPSettingsForm,
   TestRegistryConnection as GCPTestRegistryConnection,
 } from "./_GCPRegistryForm";
+import { OFState } from "main/home/onboarding/state";
+import { useSnapshot } from "valtio";
 
 const Forms = {
   aws: {
@@ -64,35 +69,32 @@ const FormTitle = {
 };
 
 type Props = {
-  provider: SupportedProviders;
-  onSaveCredentials: (credentials: any) => void;
-  onSaveSettings: (settings: any) => void;
-  onSuccess: () => void;
-  project: { id: number; name: string };
   currentStep: "credentials" | "settings" | "test_connection";
-  goBack: () => void;
-  enable_go_back: boolean;
 };
 
-const FormFlowWrapper: React.FC<Props> = ({
-  onSaveCredentials,
-  onSaveSettings,
-  onSuccess,
-  provider,
-  project,
-  currentStep,
-  goBack,
-  enable_go_back,
-}) => {
+const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
+  const snap = useSnapshot(StateHandler);
+
+  const provider = snap.connected_registry.provider as SupportedProviders;
+  const project = snap.project;
+
+  const handleContinue = (data?: any) => {
+    OFState.actions.nextStep("continue", data);
+  };
+
+  const handleGoBack = () => {
+    OFState.actions.nextStep("go_back");
+  };
+
   const nextFormStep = (
     data?: Partial<Exclude<ConnectedRegistryConfig, SkipRegistryConnection>>
   ) => {
     if (currentStep === "credentials") {
-      onSaveCredentials(data.credentials);
+      handleContinue(data.credentials);
     } else if (currentStep === "settings") {
-      onSaveSettings(data.settings);
+      handleContinue(data.settings);
     } else if (currentStep === "test_connection") {
-      onSuccess();
+      handleContinue();
     }
   };
 
@@ -118,7 +120,7 @@ const FormFlowWrapper: React.FC<Props> = ({
       <Header>
         <FormHeader>
           {currentStep !== "test_connection" && (
-            <CloseButton onClick={() => goBack()}>
+            <CloseButton onClick={() => handleGoBack()}>
               <i className="material-icons">keyboard_backspace</i>
             </CloseButton>
           )}

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

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

+ 39 - 47
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -11,45 +11,48 @@ import ProviderSelector, {
 
 import FormFlowWrapper from "./forms/FormFlow";
 import ConnectExternalCluster from "./forms/_ConnectExternalCluster";
-import { SupportedProviders } from "../../types";
 import backArrow from "assets/back_arrow.png";
 import { SharedStatus } from "./forms/SharedStatus";
+import { useSnapshot } from "valtio";
+import { OFState } from "../../state";
 
-type Props = {
-  provider: SupportedProviders | "external";
-  enable_go_back: boolean;
-  project: {
-    id: number;
-    name: string;
-  };
-  shouldProvisionRegistry: boolean;
-  onSelectProvider: (provider: SupportedProviders | "external") => void;
-  onSaveCredentials: (credentials: any) => void;
-  onSaveSettings: (settings: any) => void;
-  onSuccess: () => void;
-  onSkip: () => void;
-  goBack: (data?: any) => void;
-};
+type Props = {};
 
-const ProvisionResources: React.FC<Props> = ({
-  provider,
-  project,
-  shouldProvisionRegistry,
-  onSelectProvider,
-  onSaveCredentials,
-  onSaveSettings,
-  onSuccess,
-
-  enable_go_back,
-  goBack,
-}) => {
+const ProvisionResources: React.FC<Props> = () => {
+  const snap = useSnapshot(OFState);
   const { step } = useParams<{ step: any }>();
   const [infraStatus, setInfraStatus] = useState<{
     hasError: boolean;
     description?: string;
   }>(null);
 
+  const shouldProvisionRegistry = !!snap.StateHandler.connected_registry?.skip;
+  const provider = snap.StateHandler.provision_resources?.provider;
+  const project = snap.StateHandler.project;
+  const enableGoBack =
+    snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow;
+
+  const handleContinue = (data?: any) => {
+    OFState.actions.nextStep("continue", data);
+  };
+
+  const handleGoBack = (data?: any) => {
+    OFState.actions.nextStep("go_back", data);
+  };
+
+  const handleSelectProvider = (provider: string) => {
+    if (provider !== "external") {
+      OFState.actions.nextStep("continue", provider);
+      return;
+    }
+    OFState.actions.nextStep("skip");
+  };
+
   const renderSaveButton = () => {
+    if (typeof infraStatus?.hasError !== "boolean") {
+      return;
+    }
+
     if (infraStatus && !infraStatus.hasError) {
       return (
         <>
@@ -57,7 +60,7 @@ const ProvisionResources: React.FC<Props> = ({
           <SaveButton
             text="Continue"
             disabled={false}
-            onClick={onSuccess}
+            onClick={() => handleContinue()}
             makeFlush={true}
             clearPosition={true}
             statusPosition="right"
@@ -73,7 +76,7 @@ const ProvisionResources: React.FC<Props> = ({
             text="Resolve Errors"
             status="Encountered errors while provisioning."
             disabled={false}
-            onClick={() => goBack(infraStatus.description)}
+            onClick={() => handleGoBack(infraStatus.description)}
             makeFlush={true}
             clearPosition={true}
             statusPosition="right"
@@ -101,16 +104,7 @@ const ProvisionResources: React.FC<Props> = ({
     switch (step) {
       case "credentials":
       case "settings":
-        return (
-          <FormFlowWrapper
-            provider={provider}
-            currentStep={step}
-            onSaveCredentials={onSaveCredentials}
-            onSaveSettings={onSaveSettings}
-            project={project}
-            goBack={goBack}
-          />
-        );
+        return <FormFlowWrapper currentStep={step} />;
       case "status":
         return (
           <>
@@ -127,17 +121,15 @@ const ProvisionResources: React.FC<Props> = ({
       case "connect_own_cluster":
         return (
           <ConnectExternalCluster
-            nextStep={onSuccess}
+            nextStep={handleContinue}
             project={project}
-            goBack={goBack}
+            goBack={handleGoBack}
           />
         );
       default:
         return (
           <ProviderSelector
-            selectProvider={(provider) => {
-              onSelectProvider(provider);
-            }}
+            selectProvider={handleSelectProvider}
             options={
               shouldProvisionRegistry
                 ? provisionerOptions
@@ -150,10 +142,10 @@ const ProvisionResources: React.FC<Props> = ({
 
   return (
     <div>
-      {enable_go_back && (
+      {enableGoBack && (
         <BackButton
           onClick={() => {
-            goBack();
+            handleGoBack();
           }}
         >
           <BackButtonImg src={backArrow} />

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

@@ -1,30 +0,0 @@
-import React from "react";
-import { useSnapshot } from "valtio";
-import { OFState } from "../../state";
-import ProvisionResources from "./ProvisionResources";
-
-const ProvisionResourcesWrapper = () => {
-  const snap = useSnapshot(OFState);
-  return (
-    <ProvisionResources
-      shouldProvisionRegistry={snap.StateHandler.connected_registry?.skip}
-      provider={snap.StateHandler.provision_resources?.provider}
-      project={snap.StateHandler.project}
-      onSelectProvider={(provider: string) => {
-        if (provider !== "external") {
-          OFState.actions.nextStep("continue", provider);
-          return;
-        }
-        OFState.actions.nextStep("skip");
-      }}
-      onSaveCredentials={(data) => OFState.actions.nextStep("continue", data)}
-      onSaveSettings={(data) => OFState.actions.nextStep("continue", data)}
-      onSuccess={() => OFState.actions.nextStep("continue")}
-      onSkip={() => OFState.actions.nextStep("skip")}
-      enable_go_back={snap.StepHandler.canGoBack && !snap.StepHandler.isSubFlow}
-      goBack={(data: any) => OFState.actions.nextStep("go_back", data)}
-    />
-  );
-};
-
-export default ProvisionResourcesWrapper;

+ 26 - 17
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/FormFlow.tsx

@@ -1,4 +1,7 @@
-import { ProvisionerConfig } from "main/home/onboarding/state/StateHandler";
+import {
+  ProvisionerConfig,
+  StateHandler,
+} from "main/home/onboarding/state/StateHandler";
 import {
   SkipProvisionConfig,
   SupportedProviders,
@@ -21,6 +24,8 @@ import {
   CredentialsForm as GCPCredentialsForm,
   SettingsForm as GCPSettingsForm,
 } from "./_GCPProvisionerForm";
+import { OFState } from "main/home/onboarding/state";
+import { useSnapshot } from "valtio";
 
 const Forms = {
   aws: {
@@ -61,29 +66,33 @@ const FormTitle = {
 };
 
 type Props = {
-  onSaveCredentials: (credentials: any) => void;
-  onSaveSettings: (settings: any) => void;
-  provider: SupportedProviders | "external";
   currentStep: "credentials" | "settings";
-  project: { id: number; name: string };
-  goBack: () => void;
 };
 
-const FormFlowWrapper: React.FC<Props> = ({
-  onSaveCredentials,
-  onSaveSettings,
-  provider,
-  currentStep,
-  project,
-  goBack,
-}) => {
+const FormFlowWrapper: React.FC<Props> = ({ currentStep }) => {
+  const snap = useSnapshot(StateHandler);
+
+  const provider = snap.provision_resources?.provider as
+    | SupportedProviders
+    | "external";
+
+  const project = snap.project;
+
+  const handleContinue = (data: any) => {
+    OFState.actions.nextStep("continue", data);
+  };
+
+  const handleGoBack = () => {
+    OFState.actions.nextStep("go_back");
+  };
+
   const nextFormStep = (
     data?: Partial<Exclude<ProvisionerConfig, SkipProvisionConfig>>
   ) => {
     if (currentStep === "credentials") {
-      onSaveCredentials(data);
+      handleContinue(data);
     } else if (currentStep === "settings") {
-      onSaveSettings(data);
+      handleContinue(data);
     }
   };
 
@@ -110,7 +119,7 @@ const FormFlowWrapper: React.FC<Props> = ({
     <FormWrapper>
       <Header>
         <FormHeader>
-          <CloseButton onClick={() => goBack()}>
+          <CloseButton onClick={() => handleGoBack()}>
             <i className="material-icons">keyboard_backspace</i>
           </CloseButton>
           {FormTitle[provider] && <img src={FormTitle[provider].icon} />}

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

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

+ 4 - 1
dashboard/webpack.config.js

@@ -11,7 +11,10 @@ const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
 const TerserPlugin = require("terser-webpack-plugin");
 
 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) => {
     prev[`process.env.${next}`] = JSON.stringify(env[next]);
     return prev;

+ 1 - 1
docker/Dockerfile

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

+ 44 - 0
ee/billing/types.go

@@ -38,6 +38,49 @@ type Plan struct {
 	Features   []PlanFeature `json:"features"`
 }
 
+type CreatePlanRequest struct {
+	Name               string                   `json:"name"`
+	IsActive           bool                     `json:"is_active"`
+	IsPublic           bool                     `json:"is_public"`
+	IsTrialAllowed     bool                     `json:"is_trial_allowed"`
+	PerMonthPriceCents uint                     `json:"per_month_price_cents"`
+	PerYearPriceCents  uint                     `json:"per_year_price_cents"`
+	ReplacePlanID      *string                  `json:"replace_plan_id"`
+	Features           []*CreatePlanFeature     `json:"features"`
+	TeamsAccess        []*CreatePlanTeamsAccess `json:"teams_access"`
+}
+
+type CreatePlanFeature struct {
+	FeatureID string `json:"feature_id"`
+	SpecID    string `json:"spec_id"`
+	Display   string `json:"display"`
+	Sort      uint   `json:"sort"`
+	IsActive  bool   `json:"is_active"`
+}
+
+type CreatePlanTeamsAccess struct {
+	TeamID string `json:"team_id"`
+	Revoke bool   `json:"revoke"`
+}
+
+type CreateFeatureSpecRequest struct {
+	Name          string `json:"name"`
+	RecordPeriod  string `json:"record_period"`
+	Aggregation   string `json:"aggregation"`
+	MaxLimit      uint   `json:"max_limit"`
+	UnitPrice     uint   `json:"unit_price"`
+	UnitsIncluded uint   `json:"units_included"`
+}
+
+type CreateFeaturespecResponse struct {
+	*CreateFeatureSpecRequest
+	ID string `json:"id"`
+}
+
+type ListFeaturesResponse struct {
+	Results []Feature `json:"results"`
+}
+
 type ListPlansResponse struct {
 	Results []Plan `json:"results"`
 }
@@ -50,6 +93,7 @@ type PlanFeature struct {
 }
 
 type Feature struct {
+	ID   string `json:"id"`
 	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
 # -------------------------
-FROM node:latest as build-webpack
+FROM node:lts as build-webpack
 WORKDIR /webpack
 
 COPY ./dashboard ./

+ 22 - 0
internal/billing/billing.go

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

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

@@ -135,7 +135,7 @@ func (a *AWSIntegration) GetBearerToken(
 
 	tok, err := generator.GetWithOptions(&token.GetTokenOptions{
 		Session:   sess,
-		ClusterID: clusterID,
+		ClusterID: clusterIDGuess,
 	})
 
 	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 '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
 
 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 '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
 
 mkdir -p /release/windows

+ 1 - 1
services/job_sidecar_container/Dockerfile

@@ -5,6 +5,6 @@ RUN apk --no-cache add procps coreutils
 
 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"]

+ 9 - 1
services/job_sidecar_container/job_killer.sh

@@ -1,6 +1,6 @@
 #!/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. 
 # 
@@ -24,9 +24,11 @@ if $kill_child_procs
 then
   grace_period_seconds=$2
   target=$3
+  sidecar=$4
 else
   grace_period_seconds=$1
   target=$2
+  sidecar=$3
 fi  
 
 pattern="$(printf '[%s]%s' $(echo $target | cut -c 1) $(echo $target | cut -c 2-))"
@@ -87,4 +89,10 @@ if [ -n "$target_pid" ]; then
     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

+ 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