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

Merge branch 'beta.3.provisioning-integration' of https://github.com/porter-dev/porter into beta.3.integration-frontend

jusrhee 5 лет назад
Родитель
Сommit
79eed15103
41 измененных файлов с 1532 добавлено и 491 удалено
  1. 19 3
      cmd/app/main.go
  2. 1 0
      cmd/migrate/main.go
  3. 2 1
      dashboard/src/components/ResourceTab.tsx
  4. 13 2
      dashboard/src/components/StatusIndicator.tsx
  5. 34 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  6. 4 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  7. 13 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  8. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  9. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  10. 4 1
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  11. 8 1
      dashboard/src/shared/api.tsx
  12. 7 0
      docker-compose.dev.yaml
  13. 20 18
      go.mod
  14. 63 339
      go.sum
  15. 22 0
      internal/adapter/redis.go
  16. 1 0
      internal/config/config.go
  17. 7 0
      internal/config/redis.go
  18. 0 2
      internal/forms/helper_test.go
  19. 23 0
      internal/forms/infra.go
  20. 83 2
      internal/kubernetes/agent.go
  21. 30 0
      internal/kubernetes/provisioner/aws/aws.go
  22. 18 0
      internal/kubernetes/provisioner/aws/ecr/ecr.go
  23. 18 0
      internal/kubernetes/provisioner/aws/eks/eks.go
  24. 133 0
      internal/kubernetes/provisioner/global_stream.go
  25. 236 0
      internal/kubernetes/provisioner/provisioner.go
  26. 63 0
      internal/kubernetes/provisioner/resource_stream.go
  27. 91 0
      internal/models/infra.go
  28. 31 15
      internal/models/integrations/aws.go
  29. 3 0
      internal/models/project.go
  30. 0 4
      internal/repository/gorm/auth_test.go
  31. 40 18
      internal/repository/gorm/helpers_test.go
  32. 75 0
      internal/repository/gorm/infra.go
  33. 90 0
      internal/repository/gorm/infra_test.go
  34. 1 0
      internal/repository/gorm/repository.go
  35. 13 0
      internal/repository/infra.go
  36. 91 0
      internal/repository/memory/infra.go
  37. 1 0
      internal/repository/repository.go
  38. 17 70
      server/api/api.go
  39. 61 3
      server/api/k8s_handler.go
  40. 147 0
      server/api/provision_handler.go
  41. 47 0
      server/router/router.go

+ 19 - 3
cmd/app/main.go

@@ -15,6 +15,7 @@ import (
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/server/router"
 
+	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
@@ -29,6 +30,14 @@ func main() {
 		return
 	}
 
+	redis, err := adapter.NewRedisClient(&appConf.Redis)
+	prov.InitGlobalStream(redis)
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+
 	err = db.AutoMigrate(
 		&models.Project{},
 		&models.Role{},
@@ -40,6 +49,7 @@ func main() {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.AWSInfra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -66,9 +76,10 @@ func main() {
 	repo := gorm.NewRepository(db, &key)
 
 	a, _ := api.New(&api.AppConfig{
-		Logger:     logger,
-		Repository: repo,
-		ServerConf: appConf.Server,
+		Logger:      logger,
+		Repository:  repo,
+		ServerConf:  appConf.Server,
+		RedisClient: redis,
 	})
 
 	appRouter := router.New(a)
@@ -85,7 +96,12 @@ func main() {
 		IdleTimeout:  appConf.Server.TimeoutIdle,
 	}
 
+	errorChan := make(chan error)
+
+	go prov.GlobalStreamListener(redis, repo.AWSInfra, errorChan)
+
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 		log.Fatal("Server startup failed", err)
 	}
+
 }

+ 1 - 0
cmd/migrate/main.go

@@ -36,6 +36,7 @@ func main() {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.AWSInfra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 2 - 1
dashboard/src/components/ResourceTab.tsx

@@ -204,7 +204,7 @@ const Info = styled.div`
   display: flex;
   flex-direction: row;
   align-items: center;
-  width: 100%;
+  width: 80%;
   height: 100%;
 `;
 
@@ -217,6 +217,7 @@ const Metadata = styled.div`
 
 const Status = styled.div`
   display: flex;
+  width; 20%;
   font-size: 12px;
   text-transform: capitalize;
   justify-content: flex-end;

+ 13 - 2
dashboard/src/components/StatusIndicator.tsx

@@ -32,9 +32,20 @@ export default class StatusIndicator extends Component<PropsType, StateType> {
     if (chartStatus === 'deployed') {
       for (var uid in this.props.controllers) {
         let value = this.props.controllers[uid]
-        let status = this.getAvailability(value.metadata.kind, value)
-        if (!status) {
+        let available = this.getAvailability(value.metadata.kind, value)
+        let progressing = true
+
+        this.props.controllers[uid]?.status?.conditions?.forEach((condition: any) => {
+          if (condition.type == "Progressing" && condition.status == "False"
+              && condition.reason == "ProgressDeadlineExceeded") {
+            progressing = false
+          }
+        })
+
+        if (!available && progressing) {
           return 'loading'
+        } else if (!available && !progressing) {
+          return 'failed'
         }
       }
       return 'deployed'

+ 34 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -392,11 +392,23 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   getChartStatus = (chartStatus: string) => {
     if (chartStatus === 'deployed') {
+
       for (var uid in this.state.controllers) {
         let value = this.state.controllers[uid]
-        let status = this.getAvailability(value.metadata.kind, value)
-        if (!status) {
+        let available = this.getAvailability(value.metadata.kind, value)
+        let progressing = true
+
+        this.state.controllers[uid]?.status?.conditions?.forEach((condition: any) => {
+          if (condition.type == "Progressing" && condition.status == "False" 
+              && condition.reason == "ProgressDeadlineExceeded") {
+            progressing = false
+          }
+        })
+        
+        if (!available && progressing) {
           return 'loading'
+        } else if (!available && !progressing) {
+          return 'failed'
         }
       }
       return 'deployed'
@@ -417,12 +429,32 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+
     this.getChartData(this.props.currentChart);
     this.getControllers(this.props.currentChart)
     this.setControllerWebsockets(
       ["deployment", "statefulset", "daemonset", "replicaset"],
       this.props.currentChart 
     );
+
+    console.log(this.props.currentChart.name)
+
+    api.getIngress('<token>', { 
+      cluster_id: currentCluster.id,
+    }, {
+      id: currentProject.id,
+      name: `${this.props.currentChart.name}-docker`,
+      namespace: `${this.props.currentChart.namespace}`
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return
+      }
+      if (res.data) {
+        this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+      }
+    })
   }
 
   componentDidUpdate(prevProps: PropsType) {

+ 4 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -51,12 +51,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
 
     let image = this.props.currentChart.config?.image;
-    if (image?.repository && image.tag) {
-      this.setState({ 
-        selectedImageUrl: image.repository, 
-        selectedTag: image.tag 
-      });
-    }
+    this.setState({ 
+      selectedImageUrl: image?.repository, 
+      selectedTag: image?.tag 
+    });
 
     api.getReleaseToken('<token>', {
       namespace: this.props.currentChart.namespace,

+ 13 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -59,7 +59,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           phase: pod?.status?.phase,
         }
       });
-      // console.log(res.data);
+      
       this.setState({ pods, raw: res.data });
     })
   }
@@ -81,7 +81,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
   getPodStatus = (status: any) => {
     if (status?.phase == 'Pending') {
-      return 'waiting'
+      return status?.containerStatuses[0].state.waiting.reason
+      // return 'waiting'
     }
 
     if (status?.phase == 'Failed') {
@@ -116,6 +117,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       >
         {
           this.state.raw.map((pod, i) => {
+            console.log('pod', pod)
             let status = this.getPodStatus(pod.status)
             return (
               <Tab 
@@ -128,7 +130,9 @@ export default class ControllerTab extends Component<PropsType, StateType> {
                   <Circle />
                   <Rail lastTab={i === this.state.raw.length - 1} />
                 </Gutter>
-                {pod.metadata?.name}
+                <Name>
+                  {pod.metadata?.name}
+                </Name>
                 <Status>
                   <StatusColor status={status} />
                   {status}
@@ -195,8 +199,14 @@ const StatusColor = styled.div`
   border-radius: 20px;
 `;
 
+const Name = styled.div`
+  width: 50%;
+  overflow: hidden;
+`
+
 const Tab = styled.div`
   width: 100%;
+  overflow: hidden;
   height: 50px;
   position: relative;
   display: flex;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -43,7 +43,7 @@ export default class Logs extends Component<PropsType, StateType> {
     if (!selectedPod.metadata?.name) return
     let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
     let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
-
+    // let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provisioning/ecr/abcdef/logs?cluster_id=${currentCluster.id}`)
     this.setState({ ws }, () => {
       if (!this.state.ws) return;
   

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -98,6 +98,7 @@ export default class StatusSection extends Component<PropsType, StateType> {
     }, (err: any, res: any) => {
       if (err) {
         setCurrentError(JSON.stringify(err));
+        this.setState({controllers: [], loading: false})
         return
       }
       this.setState({ controllers: res.data, loading: false })

+ 4 - 1
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -11,6 +11,7 @@ import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import TabRegion from '../../../../components/TabRegion';
 import ValuesWrapper from '../../../../components/values-form/ValuesWrapper';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
+import { safeDump } from 'js-yaml';
 
 type PropsType = {
   currentTemplate: any,
@@ -60,6 +61,9 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       _.set(values, key, rawValues[key]);
     }
 
+    _.set(values, "image.repository", this.state.selectedImageUrl)
+    _.set(values, "image.tag", this.state.selectedTag)
+
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
       imageURL: this.state.selectedImageUrl,
@@ -112,7 +116,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
-
     // Retrieve tab options
     let tabOptions = [] as ChoiceType[];
     this.props.form.tabs.map((tab: any, i: number) => {

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

@@ -89,6 +89,12 @@ const getMatchingPods = baseApi<{
   return `/api/projects/${pathParams.id}/k8s/pods`;
 });
 
+const getIngress = baseApi<{
+  cluster_id: number,
+}, { name: string, namespace: string, id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
+});
+
 const getRevisions = baseApi<{
   namespace: string,
   cluster_id: number,
@@ -120,7 +126,7 @@ const upgradeChartValues = baseApi<{
   cluster_id: number,
 }>('POST', pathParams => {
   let { id, name, cluster_id } = pathParams;
-  return `/api/projects/${id}/releases/${name}/upgrade/hook?cluster_id=${cluster_id}&repository=fake&commit=hash`;
+  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
 
 const getTemplates = baseApi('GET', '/api/templates');
@@ -266,6 +272,7 @@ export default {
   getChartControllers,
   getNamespaces,
   getMatchingPods,
+  getIngress,
   getRevisions,
   rollbackChart,
   upgradeChartValues,

+ 7 - 0
docker-compose.dev.yaml

@@ -33,6 +33,13 @@ services:
       - 5400:5432
     volumes:
       - database:/var/lib/postgresql/data
+  redis:
+    image: redis:latest
+    container_name: redis
+    ports:
+      - 6379:6379
+    volumes:
+      - database:/var/lib/postgresql/data
   chartmuseum:
     image: docker.io/bitnami/chartmuseum:0-debian-10
     container_name: chartmuseum

+ 20 - 18
go.mod

@@ -5,33 +5,31 @@ go 1.14
 require (
 	cloud.google.com/go v0.65.0
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
+	github.com/Azure/go-autorest/autorest/adal v0.9.5 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/aws/aws-sdk-go v1.31.6
-	github.com/containerd/containerd v1.4.1
-	github.com/cosmtrek/air v1.21.2 // indirect
-	github.com/creack/pty v1.1.11 // indirect
-	github.com/danieljoos/wincred v1.1.0 // indirect
+	github.com/containerd/containerd v1.4.1 // indirect
+	github.com/coreos/rkt v1.30.0
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
-	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
-	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0
 	github.com/go-chi/chi v4.1.2+incompatible
-	github.com/go-chi/cors v1.1.1
 	github.com/go-playground/locales v0.13.0
 	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
+	github.com/go-redis/redis v6.15.9+incompatible
+	github.com/go-redis/redis/v7 v7.4.0
+	github.com/go-redis/redis/v8 v8.3.1
 	github.com/go-test/deep v1.0.7
-	github.com/google/go-cmp v0.5.1
-	github.com/google/go-containerregistry v0.1.4
 	github.com/google/go-github v17.0.0+incompatible
-	github.com/google/go-github/v32 v32.1.0
+	github.com/google/go-querystring v1.0.0 // indirect
+	github.com/googleapis/gnostic v0.2.2 // indirect
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.4.2
-	github.com/hashicorp/consul/api v1.3.0
+	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/itchyny/gojq v0.11.1
 	github.com/itchyny/timefmt-go v0.1.1 // indirect
@@ -39,40 +37,44 @@ require (
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/json-iterator/go v1.1.10 // indirect
 	github.com/kr/pretty v0.2.0 // indirect
+	github.com/kr/text v0.2.0 // indirect
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
-	github.com/mattn/go-colorable v0.1.7 // indirect
+	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
+	github.com/onsi/ginkgo v1.14.2 // indirect
+	github.com/opentracing/opentracing-go v1.2.0 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
-	github.com/sirupsen/logrus v1.7.0
+	github.com/sirupsen/logrus v1.7.0 // indirect
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1
+	go.opentelemetry.io/otel v0.13.0 // indirect
 	golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
+	golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
-	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
+	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	google.golang.org/api v0.30.0
 	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9
 	google.golang.org/grpc v1.33.0 // indirect
-	gopkg.in/go-playground/validator.v9 v9.31.0
+	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
 	gopkg.in/yaml.v2 v2.3.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.20.2
-	helm.sh/helm v2.16.12+incompatible
+	gotest.tools/v3 v3.0.3 // indirect
 	helm.sh/helm/v3 v3.3.4
 	k8s.io/api v0.18.8
 	k8s.io/apimachinery v0.18.8
 	k8s.io/cli-runtime v0.18.8
 	k8s.io/client-go v0.18.8
-	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
 	k8s.io/helm v2.16.12+incompatible
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
+	rsc.io/letsencrypt v0.0.3 // indirect
 	sigs.k8s.io/aws-iam-authenticator v0.5.2
-	sigs.k8s.io/structured-merge-diff/v4 v4.0.1 // indirect
 	sigs.k8s.io/yaml v1.2.0
 )
 

Разница между файлами не показана из-за своего большого размера
+ 63 - 339
go.sum


+ 22 - 0
internal/adapter/redis.go

@@ -0,0 +1,22 @@
+package adapter
+
+import (
+	"context"
+	"fmt"
+
+	redis "github.com/go-redis/redis/v8"
+	"github.com/porter-dev/porter/internal/config"
+)
+
+// NewRedisClient returns a new redis client instance
+func NewRedisClient(conf *config.RedisConf) (*redis.Client, error) {
+	client := redis.NewClient(&redis.Options{
+		Addr: fmt.Sprintf("%s:%s", conf.Host, conf.Port),
+		// Username: conf.Username,
+		// Password: conf.Password,
+		// DB:       conf.DB,
+	})
+
+	_, err := client.Ping(context.Background()).Result()
+	return client, err
+}

+ 1 - 0
internal/config/config.go

@@ -13,6 +13,7 @@ type Conf struct {
 	Server ServerConf
 	Db     DBConf
 	K8s    K8sConf
+	Redis  RedisConf
 }
 
 // ServerConf is the server configuration

+ 7 - 0
internal/config/redis.go

@@ -0,0 +1,7 @@
+package config
+
+// RedisConf is the redis config required for the provisioner container
+type RedisConf struct {
+	Host string `env:"REDIS_HOST,default=redis"`
+	Port string `env:"REDIS_PORT,default=5432"`
+}

+ 0 - 2
internal/forms/helper_test.go

@@ -259,8 +259,6 @@ func initAWSIntegration(tester *tester, t *testing.T) {
 	aws := &ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),

+ 23 - 0
internal/forms/infra.go

@@ -0,0 +1,23 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateECRInfra represents the accepted values for creating an
+// ECR infra via the provisioning container
+type CreateECRInfra struct {
+	ECRName          string `json:"ecr_name" form:"required"`
+	ProjectID        uint   `json:"project_id" form:"required"`
+	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
+}
+
+// ToAWSInfra converts the form to a gorm aws infra model
+func (ce *CreateECRInfra) ToAWSInfra() (*models.AWSInfra, error) {
+	return &models.AWSInfra{
+		Kind:             models.AWSInfraECR,
+		ProjectID:        ce.ProjectID,
+		Status:           models.StatusCreating,
+		AWSIntegrationID: ce.AWSIntegrationID,
+	}, nil
+}

+ 83 - 2
internal/kubernetes/agent.go

@@ -7,10 +7,18 @@ import (
 	"io"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/models/integrations"
+
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
+	v1beta1 "k8s.io/api/extensions/v1beta1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
@@ -43,7 +51,16 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
-// GetDeployment gets the depployment given the name and namespace
+// GetIngress gets ingress given the name and namespace
+func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
+	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDeployment gets the deployment given the name and namespace
 func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
 	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
 		context.TODO(),
@@ -112,7 +129,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 		// listens for websocket closing handshake
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
-				conn.Close()
+				defer conn.Close()
 				errorchan <- nil
 				fmt.Println("Successfully closed log stream")
 				return
@@ -213,3 +230,67 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 		}
 	}
 }
+
+// ProvisionECR spawns a new provisioning pod that creates an ECR instance
+func (a *Agent) ProvisionECR(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	ecrName string,
+) (*batchv1.Job, error) {
+	prov := &provisioner.Conf{
+		ID:   fmt.Sprintf("%s-%d", ecrName, projectID),
+		Name: fmt.Sprintf("prov-%s-%d", ecrName, projectID),
+		Kind: provisioner.ECR,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		ECR: &ecr.Conf{
+			ECRName: ecrName,
+		},
+	}
+
+	return a.provision(prov)
+}
+
+// ProvisionTest spawns a new provisioning pod that tests provisioning
+func (a *Agent) ProvisionTest(
+	projectID uint,
+) (*batchv1.Job, error) {
+	prov := &provisioner.Conf{
+		ID:   fmt.Sprintf("%s-%d", "testing", projectID),
+		Name: fmt.Sprintf("prov-%s-%d", "testing", projectID),
+		Kind: provisioner.Test,
+	}
+
+	return a.provision(prov)
+}
+
+func (a *Agent) provision(
+	prov *provisioner.Conf,
+) (*batchv1.Job, error) {
+	prov.Namespace = "default"
+
+	prov.Redis = &config.RedisConf{
+		Host: "redis-master.default.svc.cluster.local",
+		Port: "6379",
+	}
+
+	prov.Postgres = &provisioner.PostgresConf{
+		Host: "postgres-postgresql.default.svc.cluster.local",
+		Port: "5432",
+	}
+
+	job, err := prov.GetProvisionerJobTemplate()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
+		context.TODO(),
+		job,
+		metav1.CreateOptions{},
+	)
+}

+ 30 - 0
internal/kubernetes/provisioner/aws/aws.go

@@ -0,0 +1,30 @@
+package aws
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf wraps the AWS integration model
+type Conf struct {
+	AWSRegion, AWSAccessKeyID, AWSSecretAccessKey string
+}
+
+// AttachAWSEnv adds the relevant AWS env for the provisioner
+func (conf *Conf) AttachAWSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_REGION",
+		Value: conf.AWSRegion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_ACCESS_KEY_ID",
+		Value: conf.AWSAccessKeyID,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_SECRET_ACCESS_KEY",
+		Value: conf.AWSSecretAccessKey,
+	})
+
+	return env
+}

+ 18 - 0
internal/kubernetes/provisioner/aws/ecr/ecr.go

@@ -0,0 +1,18 @@
+package ecr
+
+import v1 "k8s.io/api/core/v1"
+
+// Conf is the ECR cluster config required for the provisioner
+type Conf struct {
+	ECRName string
+}
+
+// AttachECREnv adds the relevant ECR env for the provisioner
+func (conf *Conf) AttachECREnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "ECR_NAME",
+		Value: conf.ECRName,
+	})
+
+	return env
+}

+ 18 - 0
internal/kubernetes/provisioner/aws/eks/eks.go

@@ -0,0 +1,18 @@
+package eks
+
+import v1 "k8s.io/api/core/v1"
+
+// Conf is the EKS cluster config required for the provisioner
+type Conf struct {
+	ClusterName string
+}
+
+// AttachEKSEnv adds the relevant EKS env for the provisioner
+func (conf *Conf) AttachEKSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "EKS_CLUSTER_NAME",
+		Value: conf.ClusterName,
+	})
+
+	return env
+}

+ 133 - 0
internal/kubernetes/provisioner/global_stream.go

@@ -0,0 +1,133 @@
+package provisioner
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/repository"
+
+	redis "github.com/go-redis/redis/v8"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GlobalStreamName is the name of the Redis stream for global operations
+const GlobalStreamName = "global"
+
+// GlobalStreamGroupName is the name of the Redis consumer group that this server
+// is a part of
+const GlobalStreamGroupName = "portersvr"
+
+// InitGlobalStream initializes the global stream if it does not exist, and the
+// global consumer group if it does not exist
+func InitGlobalStream(client *redis.Client) error {
+	// determine if the stream exists
+	x, err := client.Exists(
+		context.Background(),
+		GlobalStreamName,
+	).Result()
+
+	// if it does not exist, create group and stream
+	if x == 0 {
+		_, err := client.XGroupCreateMkStream(
+			context.Background(),
+			GlobalStreamName,
+			GlobalStreamGroupName,
+			">",
+		).Result()
+
+		return err
+	}
+
+	// otherwise, check if the group exists
+	xInfoGroups, err := client.XInfoGroups(
+		context.Background(),
+		GlobalStreamName,
+	).Result()
+
+	if err != nil {
+		return err
+	}
+
+	for _, group := range xInfoGroups {
+		// if the group exists, return with no error
+		if group.Name == GlobalStreamGroupName {
+			return nil
+		}
+	}
+
+	// if the group does not exist, create it
+	_, err = client.XGroupCreate(
+		context.Background(),
+		GlobalStreamName,
+		GlobalStreamGroupName,
+		">",
+	).Result()
+
+	return err
+}
+
+// ResourceCRUDHandler is a handler for updates to an infra resource
+type ResourceCRUDHandler interface {
+	OnCreate(id uint) error
+}
+
+// GlobalStreamListener performs an XREADGROUP operation on a given stream
+// and sends a GlobalStreamMessage to the msgChan
+func GlobalStreamListener(
+	client *redis.Client,
+	infraRepo repository.AWSInfraRepository,
+	errorChan chan error,
+) {
+	for {
+		xstreams, err := client.XReadGroup(
+			context.Background(),
+			&redis.XReadGroupArgs{
+				Group:    GlobalStreamGroupName,
+				Consumer: "portersvr-0", // just static consumer for now
+				Streams:  []string{GlobalStreamName, ">"},
+				Block:    0,
+			},
+		).Result()
+
+		if err != nil {
+			errorChan <- err
+			return
+		}
+
+		// parse messages from the global stream
+		for _, msg := range xstreams[0].Messages {
+			// parse the id to identify the infra
+			infraID, err := models.GetInfraIDFromWorkspaceID(fmt.Sprintf("%v", msg.Values["id"]))
+
+			if fmt.Sprintf("%v", msg.Values["status"]) == "created" {
+				infra, err := infraRepo.ReadAWSInfra(infraID)
+
+				if err != nil {
+					continue
+				}
+
+				infra.Status = models.StatusCreated
+
+				infra, err = infraRepo.UpdateAWSInfra(infra)
+
+				if err != nil {
+					continue
+				}
+			}
+
+			// acknowledge the message as read
+			_, err = client.XAck(
+				context.Background(),
+				GlobalStreamName,
+				GlobalStreamGroupName,
+				msg.ID,
+			).Result()
+
+			// if error, continue for now
+			if err != nil {
+				continue
+			}
+		}
+	}
+}

+ 236 - 0
internal/kubernetes/provisioner/provisioner.go

@@ -0,0 +1,236 @@
+package provisioner
+
+import (
+	batchv1 "k8s.io/api/batch/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+
+	"github.com/porter-dev/porter/internal/config"
+)
+
+// InfraOption is a type of infrastructure that can be provisioned
+type InfraOption string
+
+// The list of infra options
+const (
+	Test InfraOption = "test"
+	ECR  InfraOption = "ecr"
+	EKS  InfraOption = "eks"
+)
+
+// Conf is the config required to start a provisioner container
+type Conf struct {
+	Kind      InfraOption
+	Name      string
+	Namespace string
+	ID        string
+	Redis     *config.RedisConf
+	Postgres  *PostgresConf
+
+	// provider-specific configurations
+	AWS *aws.Conf
+	ECR *ecr.Conf
+	EKS *eks.Conf
+}
+
+// PostgresConf is the postgres config for the provisioner container
+type PostgresConf struct {
+	Host string
+	Port string
+}
+
+// GetProvisionerJobTemplate returns the manifest that should be applied to
+// create a provisioning job
+func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
+	env := make([]v1.EnvVar, 0)
+
+	env = conf.attachDefaultEnv(env)
+
+	ttl := int32(3600)
+	backoffLimit := int32(3)
+
+	labels := map[string]string{
+		"app": "provisioner",
+	}
+
+	args := make([]string, 0)
+
+	if conf.Kind == Test {
+		args = []string{"test", "hello"}
+	} else if conf.Kind == ECR {
+		args = []string{"ecr"}
+		env = conf.AWS.AttachAWSEnv(env)
+		env = conf.ECR.AttachECREnv(env)
+	} else if conf.Kind == EKS {
+		args = []string{"eks"}
+		env = conf.AWS.AttachAWSEnv(env)
+		env = conf.EKS.AttachEKSEnv(env)
+	}
+
+	return &batchv1.Job{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      conf.Name,
+			Namespace: conf.Namespace,
+			Labels:    labels,
+		},
+		Spec: batchv1.JobSpec{
+			TTLSecondsAfterFinished: &ttl,
+			BackoffLimit:            &backoffLimit,
+			Template: v1.PodTemplateSpec{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: labels,
+				},
+				Spec: v1.PodSpec{
+					RestartPolicy: v1.RestartPolicyOnFailure,
+					Containers: []v1.Container{
+						{
+							Name:  "provisioner",
+							Image: "gcr.io/porter-dev-273614/provisioner:latest",
+							Args:  args,
+							Env:   env,
+							VolumeMounts: []v1.VolumeMount{
+								v1.VolumeMount{
+									MountPath: "/.terraform/plugin-cache",
+									Name:      "tf-cache",
+									ReadOnly:  true,
+								},
+							},
+						},
+					},
+					Volumes: []v1.Volume{
+						v1.Volume{
+							Name: "tf-cache",
+							VolumeSource: v1.VolumeSource{
+								PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{
+									ClaimName: "tf-cache-pvc",
+									ReadOnly:  true,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}, nil
+}
+
+// GetRedisStreamID returns the stream id that should be used
+func (conf *Conf) GetRedisStreamID() string {
+	return conf.ID
+}
+
+// GetTFWorkspaceID returns the workspace id that should be used
+func (conf *Conf) GetTFWorkspaceID() string {
+	return conf.ID
+}
+
+// attaches the env variables required by all provisioner instances
+func (conf *Conf) attachDefaultEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = conf.addRedisEnv(env)
+	env = conf.addPostgresEnv(env)
+	env = conf.addTFEnv(env)
+
+	return env
+}
+
+// adds the env variables required for the Redis stream
+func (conf *Conf) addRedisEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_ENABLED",
+		Value: "true",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_HOST",
+		Value: conf.Redis.Host,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_PORT",
+		Value: conf.Redis.Port,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_USER",
+		Value: "default",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name: "REDIS_PASS",
+		ValueFrom: &v1.EnvVarSource{
+			SecretKeyRef: &v1.SecretKeySelector{
+				LocalObjectReference: v1.LocalObjectReference{
+					Name: "redis",
+				},
+				Key: "redis-password",
+			},
+		},
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_STREAM_ID",
+		Value: conf.GetRedisStreamID(),
+	})
+
+	return env
+}
+
+// adds the env variables required for the PG backend
+func (conf *Conf) addPostgresEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "PG_HOST",
+		Value: conf.Postgres.Host,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PG_PORT",
+		Value: conf.Postgres.Port,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PG_USER",
+		Value: "postgres",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name: "PG_PASS",
+		ValueFrom: &v1.EnvVarSource{
+			SecretKeyRef: &v1.SecretKeySelector{
+				LocalObjectReference: v1.LocalObjectReference{
+					Name: "postgres-postgresql",
+				},
+				Key: "postgresql-password",
+			},
+		},
+	})
+
+	return env
+}
+
+func (conf *Conf) addTFEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "TF_DIR",
+		Value: "./terraform",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "TF_PLUGIN_CACHE_DIR",
+		Value: "/.terraform/plugin-cache",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "TF_PORTER_BACKEND",
+		Value: "postgres",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "TF_PORTER_WORKSPACE",
+		Value: conf.GetTFWorkspaceID(),
+	})
+
+	return env
+}

+ 63 - 0
internal/kubernetes/provisioner/resource_stream.go

@@ -0,0 +1,63 @@
+package provisioner
+
+import (
+	"context"
+	"fmt"
+
+	redis "github.com/go-redis/redis/v8"
+	"github.com/gorilla/websocket"
+)
+
+// ResourceStream performs an XREAD operation on the given stream and outputs it to the given websocket conn.
+func ResourceStream(client *redis.Client, streamName string, conn *websocket.Conn) error {
+	errorchan := make(chan error)
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			_, _, err := conn.ReadMessage()
+
+			if err != nil {
+				defer conn.Close()
+				errorchan <- err
+				return
+			}
+		}
+	}()
+
+	go func() {
+		lastID := "0-0"
+
+		for {
+
+			xstream, err := client.XRead(
+				context.Background(),
+				&redis.XReadArgs{
+					Streams: []string{streamName, lastID},
+					Block:   0,
+				},
+			).Result()
+
+			if err != nil {
+				return
+			}
+
+			messages := xstream[0].Messages
+			lastID = messages[len(messages)-1].ID
+
+			if writeErr := conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprint(xstream))); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		}
+	}()
+
+	for {
+		select {
+		case err := <-errorchan:
+			close(errorchan)
+			client.Close()
+			return err
+		}
+	}
+}

+ 91 - 0
internal/models/infra.go

@@ -0,0 +1,91 @@
+package models
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"gorm.io/gorm"
+)
+
+// InfraStatus is the status that an infrastructure can take
+type InfraStatus string
+
+// The allowed statuses
+const (
+	StatusCreating InfraStatus = "creating"
+	StatusCreated  InfraStatus = "created"
+)
+
+// AWSInfraKind is the kind that aws infra can be
+type AWSInfraKind string
+
+// The supported AWS infra kinds
+const (
+	AWSInfraECR AWSInfraKind = "ecr"
+	AWSInfraEKS AWSInfraKind = "eks"
+)
+
+// AWSInfra represents the metadata for an infrastructure type provisioned on
+// AWS
+type AWSInfra struct {
+	gorm.Model
+
+	// The type of infra that was provisioned
+	Kind AWSInfraKind `json:"kind"`
+
+	// The project that this infra belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Status is the status of the infra
+	Status InfraStatus `json:"status"`
+
+	// The AWS integration that was used to create the infra
+	AWSIntegrationID uint
+}
+
+// AWSInfraExternal is an external AWSInfra to be shared over REST
+type AWSInfraExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The type of infra that was provisioned
+	Kind AWSInfraKind `json:"kind"`
+
+	// Status is the status of the infra
+	Status InfraStatus `json:"status"`
+}
+
+// Externalize generates an external AWSInfra to be shared over REST
+func (ai *AWSInfra) Externalize() *AWSInfraExternal {
+	return &AWSInfraExternal{
+		ID:        ai.ID,
+		ProjectID: ai.ProjectID,
+		Kind:      ai.Kind,
+		Status:    ai.Status,
+	}
+}
+
+// GetWorkspaceID returns the unique workspace id for this infra
+func (ai *AWSInfra) GetWorkspaceID() string {
+	return fmt.Sprintf("%s-%d-%d", ai.Kind, ai.ProjectID, ai.ID)
+}
+
+// GetInfraIDFromWorkspaceID returns the infra id given a workspace id
+func GetInfraIDFromWorkspaceID(workspaceID string) (uint, error) {
+	strArr := strings.Split(workspaceID, "-")
+
+	if len(strArr) != 3 {
+		return 0, fmt.Errorf("workspace id improperly formatted")
+	}
+
+	u, err := strconv.ParseUint(strArr[2], 10, 64)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return uint(u), nil
+}

+ 31 - 15
internal/models/integrations/aws.go

@@ -4,6 +4,7 @@ import (
 	"gorm.io/gorm"
 
 	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/sts"
 
 	"github.com/aws/aws-sdk-go/aws/credentials"
 	"github.com/aws/aws-sdk-go/aws/session"
@@ -21,11 +22,8 @@ type AWSIntegration struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
-	// The AWS entity this is linked to (individual or organization)
-	AWSEntityID string `json:"aws-entity-id"`
-
-	// The AWS caller identity (ARN) which linked this service
-	AWSCallerID string `json:"aws-caller-id"`
+	// The AWS arn this is integration is linked to
+	AWSArn string `json:"aws_arn"`
 
 	// The optional AWS region (required by some session configurations)
 	AWSRegion string `json:"aws_region"`
@@ -58,21 +56,17 @@ type AWSIntegrationExternal struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
-	// The AWS entity this is linked to (individual or organization)
-	AWSEntityID string `json:"aws-entity-id"`
-
-	// The AWS caller identity (ARN) which linked this service
-	AWSCallerID string `json:"aws-caller-id"`
+	// The AWS arn this is integration is linked to
+	AWSArn string `json:"aws_arn"`
 }
 
 // Externalize generates an external KubeIntegration to be shared over REST
 func (a *AWSIntegration) Externalize() *AWSIntegrationExternal {
 	return &AWSIntegrationExternal{
-		ID:          a.ID,
-		UserID:      a.UserID,
-		ProjectID:   a.ProjectID,
-		AWSEntityID: a.AWSEntityID,
-		AWSCallerID: a.AWSCallerID,
+		ID:        a.ID,
+		UserID:    a.UserID,
+		ProjectID: a.ProjectID,
+		AWSArn:    a.AWSArn,
 	}
 }
 
@@ -111,6 +105,28 @@ func (a *AWSIntegration) GetSession() (*session.Session, error) {
 	})
 }
 
+// PopulateAWSArn uses the access key/secret to get the caller identity, and
+// attaches it to the AWS integration
+func (a *AWSIntegration) PopulateAWSArn() error {
+	sess, err := a.GetSession()
+
+	if err != nil {
+		return err
+	}
+
+	svc := sts.New(sess)
+
+	result, err := svc.GetCallerIdentity(&sts.GetCallerIdentityInput{})
+
+	if err != nil {
+		return err
+	}
+
+	a.AWSArn = *result.Arn
+
+	return nil
+}
+
 // GetBearerToken retrieves a bearer token for an AWS account
 func (a *AWSIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,

+ 3 - 0
internal/models/project.go

@@ -26,6 +26,9 @@ type Project struct {
 	// linked helm repos
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
+	// provisioned aws infra
+	AWSInfras []AWSInfra `json:"aws_infras"`
+
 	// auth mechanisms
 	KubeIntegrations  []ints.KubeIntegration  `json:"kube_integrations"`
 	BasicIntegrations []ints.BasicIntegration `json:"basic_integrations"`

+ 0 - 4
internal/repository/gorm/auth_test.go

@@ -465,8 +465,6 @@ func TestCreateAWSIntegration(t *testing.T) {
 	aws := &ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),
@@ -527,8 +525,6 @@ func TestListAWSIntegrationsByProjectID(t *testing.T) {
 	expAWS := ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),

+ 40 - 18
internal/repository/gorm/helpers_test.go

@@ -13,22 +13,23 @@ import (
 )
 
 type tester struct {
-	repo         *repository.Repository
-	key          *[32]byte
-	dbFileName   string
-	initUsers    []*models.User
-	initProjects []*models.Project
-	initGRs      []*models.GitRepo
-	initRegs     []*models.Registry
-	initClusters []*models.Cluster
-	initHRs      []*models.HelmRepo
-	initCCs      []*models.ClusterCandidate
-	initKIs      []*ints.KubeIntegration
-	initBasics   []*ints.BasicIntegration
-	initOIDCs    []*ints.OIDCIntegration
-	initOAuths   []*ints.OAuthIntegration
-	initGCPs     []*ints.GCPIntegration
-	initAWSs     []*ints.AWSIntegration
+	repo          *repository.Repository
+	key           *[32]byte
+	dbFileName    string
+	initUsers     []*models.User
+	initProjects  []*models.Project
+	initGRs       []*models.GitRepo
+	initRegs      []*models.Registry
+	initClusters  []*models.Cluster
+	initHRs       []*models.HelmRepo
+	initAWSInfras []*models.AWSInfra
+	initCCs       []*models.ClusterCandidate
+	initKIs       []*ints.KubeIntegration
+	initBasics    []*ints.BasicIntegration
+	initOIDCs     []*ints.OIDCIntegration
+	initOAuths    []*ints.OAuthIntegration
+	initGCPs      []*ints.GCPIntegration
+	initAWSs      []*ints.AWSIntegration
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -56,6 +57,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.AWSInfra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -294,8 +296,6 @@ func initAWSIntegration(tester *tester, t *testing.T) {
 	aws := &ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),
@@ -435,3 +435,25 @@ func initHelmRepo(tester *tester, t *testing.T) {
 
 	tester.initHRs = append(tester.initHRs, hr)
 }
+
+func initAWSInfra(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	infra := &models.AWSInfra{
+		Kind:      models.AWSInfraECR,
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra, err := tester.repo.AWSInfra.CreateAWSInfra(infra)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initAWSInfras = append(tester.initAWSInfras, infra)
+}

+ 75 - 0
internal/repository/gorm/infra.go

@@ -0,0 +1,75 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AWSInfraRepository uses gorm.DB for querying the database
+type AWSInfraRepository struct {
+	db *gorm.DB
+}
+
+// NewAWSInfraRepository returns a AWSInfraRepository which uses
+// gorm.DB for querying the database
+func NewAWSInfraRepository(db *gorm.DB) repository.AWSInfraRepository {
+	return &AWSInfraRepository{db}
+}
+
+// CreateAWSInfra creates a new aws infra
+func (repo *AWSInfraRepository) CreateAWSInfra(infra *models.AWSInfra) (*models.AWSInfra, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", infra.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("AWSInfras")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(infra); err != nil {
+		return nil, err
+	}
+
+	return infra, nil
+}
+
+// ReadAWSInfra gets a aws infra specified by a unique id
+func (repo *AWSInfraRepository) ReadAWSInfra(id uint) (*models.AWSInfra, error) {
+	infra := &models.AWSInfra{}
+
+	if err := repo.db.Where("id = ?", id).First(&infra).Error; err != nil {
+		return nil, err
+	}
+
+	return infra, nil
+}
+
+// ListAWSInfrasByProjectID finds all aws infras
+// for a given project id
+func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
+	projectID uint,
+) ([]*models.AWSInfra, error) {
+	infras := []*models.AWSInfra{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&infras).Error; err != nil {
+		return nil, err
+	}
+
+	return infras, nil
+}
+
+// UpdateAWSInfra modifies an existing AWSInfra in the database
+func (repo *AWSInfraRepository) UpdateAWSInfra(
+	ai *models.AWSInfra,
+) (*models.AWSInfra, error) {
+	if err := repo.db.Save(ai).Error; err != nil {
+		return nil, err
+	}
+
+	return ai, nil
+}

+ 90 - 0
internal/repository/gorm/infra_test.go

@@ -0,0 +1,90 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateAWSInfra(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_aws_infra.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	infra := &models.AWSInfra{
+		Kind:      models.AWSInfraECR,
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra, err := tester.repo.AWSInfra.CreateAWSInfra(infra)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	infra, err = tester.repo.AWSInfra.ReadAWSInfra(infra.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "ecr"
+	if infra.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, infra.Model.ID)
+	}
+
+	if infra.Kind != models.AWSInfraECR {
+		t.Errorf("incorrect aws infra kind: expected %s, got %s\n", models.AWSInfraECR, infra.Kind)
+	}
+
+	if infra.Status != models.StatusCreated {
+		t.Errorf("incorrect aws infra status: expected %s, got %s\n", models.StatusCreated, infra.Status)
+	}
+}
+
+func TestListAWSInfrasByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_aws_infras.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initAWSInfra(tester, t)
+	defer cleanup(tester, t)
+
+	infras, err := tester.repo.AWSInfra.ListAWSInfrasByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(infras) != 1 {
+		t.Fatalf("length of aws infras incorrect: expected %d, got %d\n", 1, len(infras))
+	}
+
+	// make sure data is correct
+	expAWSInfra := models.AWSInfra{
+		Kind:      "ecr",
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra := infras[0]
+
+	// reset fields for reflect.DeepEqual
+	infra.Model = gorm.Model{}
+
+	if diff := deep.Equal(expAWSInfra, *infra); diff != nil {
+		t.Errorf("incorrect aws infra")
+		t.Error(diff)
+	}
+}

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

@@ -17,6 +17,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Cluster:          NewClusterRepository(db, key),
 		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
+		AWSInfra:         NewAWSInfraRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 13 - 0
internal/repository/infra.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AWSInfraRepository represents the set of queries on the AWSInfra model
+type AWSInfraRepository interface {
+	CreateAWSInfra(repo *models.AWSInfra) (*models.AWSInfra, error)
+	ReadAWSInfra(id uint) (*models.AWSInfra, error)
+	ListAWSInfrasByProjectID(projectID uint) ([]*models.AWSInfra, error)
+	UpdateAWSInfra(repo *models.AWSInfra) (*models.AWSInfra, error)
+}

+ 91 - 0
internal/repository/memory/infra.go

@@ -0,0 +1,91 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AWSInfraRepository implements repository.AWSInfraRepository
+type AWSInfraRepository struct {
+	canQuery  bool
+	awsInfras []*models.AWSInfra
+}
+
+// NewAWSInfraRepository will return errors if canQuery is false
+func NewAWSInfraRepository(canQuery bool) repository.AWSInfraRepository {
+	return &AWSInfraRepository{
+		canQuery,
+		[]*models.AWSInfra{},
+	}
+}
+
+// CreateAWSInfra creates a new aws infra
+func (repo *AWSInfraRepository) CreateAWSInfra(
+	infra *models.AWSInfra,
+) (*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.awsInfras = append(repo.awsInfras, infra)
+	infra.ID = uint(len(repo.awsInfras))
+
+	return infra, nil
+}
+
+// ReadAWSInfra finds a aws infra by id
+func (repo *AWSInfraRepository) ReadAWSInfra(
+	id uint,
+) (*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.awsInfras) || repo.awsInfras[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.awsInfras[index], nil
+}
+
+// ListAWSInfrasByProjectID finds all aws infras
+// for a given project id
+func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
+	projectID uint,
+) ([]*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.AWSInfra, 0)
+
+	for _, infra := range repo.awsInfras {
+		if infra != nil && infra.ProjectID == projectID {
+			res = append(res, infra)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateAWSInfra modifies an existing AWSInfra in the database
+func (repo *AWSInfraRepository) UpdateAWSInfra(
+	ai *models.AWSInfra,
+) (*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(ai.ID-1) >= len(repo.awsInfras) || repo.awsInfras[ai.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(ai.ID - 1)
+	repo.awsInfras[index] = ai
+
+	return ai, nil
+}

+ 1 - 0
internal/repository/repository.go

@@ -10,6 +10,7 @@ type Repository struct {
 	Cluster          ClusterRepository
 	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
+	AWSInfra         AWSInfraRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 17 - 70
server/api/api.go

@@ -6,6 +6,7 @@ import (
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
 	vr "github.com/go-playground/validator/v10"
+	"github.com/go-redis/redis/v8"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
@@ -32,10 +33,11 @@ type TestAgents struct {
 
 // AppConfig is the configuration required for creating a new App
 type AppConfig struct {
-	DB         *gorm.DB
-	Logger     *lr.Logger
-	Repository *repository.Repository
-	ServerConf config.ServerConf
+	DB          *gorm.DB
+	Logger      *lr.Logger
+	Repository  *repository.Repository
+	ServerConf  config.ServerConf
+	RedisClient *redis.Client
 
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
@@ -59,6 +61,9 @@ type App struct {
 	// agents exposed for testing
 	TestAgents *TestAgents
 
+	// redis conf for redis connection
+	RedisClient *redis.Client
+
 	// oauth-specific clients
 	GithubConf *oauth2.Config
 
@@ -81,13 +86,14 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:     conf.Logger,
-		Repo:       conf.Repository,
-		ServerConf: conf.ServerConf,
-		TestAgents: conf.TestAgents,
-		db:         conf.DB,
-		validator:  validator,
-		translator: &translator,
+		Logger:      conf.Logger,
+		Repo:        conf.Repository,
+		ServerConf:  conf.ServerConf,
+		RedisClient: conf.RedisClient,
+		TestAgents:  conf.TestAgents,
+		db:          conf.DB,
+		validator:   validator,
+		translator:  &translator,
 	}
 
 	// if repository not specified, default to in-memory
@@ -116,62 +122,3 @@ func New(conf *AppConfig) (*App, error) {
 
 	return app, nil
 }
-
-// // New returns a new App instance
-// // TODO -- this should accept an app/server config
-// func New(
-// 	logger *lr.Logger,
-// 	db *gorm.DB,
-// 	repo *repository.Repository,
-// 	validator *validator.Validate,
-// 	store sessions.Store,
-// 	cookieName string,
-// 	testing bool,
-// 	isLocal bool,
-// 	githubConfig *oauth.Config,
-// 	serverConf config.ServerConf,
-// ) *App {
-// 	// for now, will just support the english translator from the
-// 	// validator/translations package
-// 	en := en.New()
-// 	uni := ut.New(en, en)
-// 	trans, _ := uni.GetTranslator("en")
-
-// 	var testAgents *TestAgents = nil
-
-// 	if testing {
-// 		memStorage := helm.StorageMap["memory"](nil, nil, "")
-
-// 		testAgents = &TestAgents{
-// 			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),
-// 			HelmTestStorageDriver: memStorage,
-// 			K8sAgent:              kubernetes.GetAgentTesting(),
-// 		}
-// 	}
-
-// 	var oauthGithubConf *oauth2.Config
-
-// 	if githubConfig != nil {
-// 		oauthGithubConf = oauth.NewGithubClient(githubConfig)
-// 	}
-
-// 	return &App{
-// 		db:           db,
-// 		logger:       logger,
-// 		repo:         repo,
-// 		validator:    validator,
-// 		store:        store,
-// 		translator:   &trans,
-// 		cookieName:   cookieName,
-// 		testing:      testing,
-// 		isLocal:      isLocal,
-// 		TestAgents:   testAgents,
-// 		GithubConfig: oauthGithubConf,
-// 		ServerConf:   serverConf,
-// 	}
-// }
-
-// // Logger returns the logger instance in use by App
-// func (app *App) Logger() *lr.Logger {
-// 	return app.logger
-// }

+ 61 - 3
server/api/k8s_handler.go

@@ -6,11 +6,10 @@ import (
 	"net/url"
 
 	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	v1 "k8s.io/api/core/v1"
-
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	v1 "k8s.io/api/core/v1"
 )
 
 // Enumeration of k8s API error codes, represented as int64
@@ -134,6 +133,65 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleGetIngress returns the ingress object given the name and namespace.
+func (app *App) HandleGetIngress(w http.ResponseWriter, r *http.Request) {
+
+	// get session to retrieve correct kubeconfig
+	_, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo: app.Repo,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	ingress, err := agent.GetIngress(namespace, name)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(ingress); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}
+
 // HandleListPods returns all pods that match the given selectors
 // TODO: Refactor repeated calls.
 func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {

+ 147 - 0
server/api/provision_handler.go

@@ -0,0 +1,147 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+)
+
+// HandleProvisionTest will create a test resource by deploying a provisioner
+// container pod
+func (app *App) HandleProvisionTest(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// create a new agent
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionTest(uint(projID))
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
+func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateECRInfra{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToAWSInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.AWSInfra.CreateAWSInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	awsInt, err := app.Repo.AWSIntegration.ReadAWSIntegration(infra.AWSIntegrationID)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionECR(
+		uint(projID),
+		awsInt,
+		form.ECRName,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New aws ecr infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleGetProvisioningLogs returns real-time logs of the provisioning process via websockets
+func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	kind := chi.URLParam(r, "kind")
+	projectID := chi.URLParam(r, "project_id")
+	infraID := chi.URLParam(r, "infra_id")
+
+	streamName := fmt.Sprintf("%s-%s-%s", kind, projectID, infraID)
+
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	err = provisioner.ResourceStream(app.RedisClient, streamName, conn)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}

+ 47 - 0
server/router/router.go

@@ -177,6 +177,39 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		// /api/projects/{project_id}/provision routes
+
+		// TODO -- restrict this endpoint
+		r.Method(
+			"GET",
+			"/projects/{project_id}/provision/test",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleProvisionTest, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/provision/ecr",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/provision/{kind}/{infra_id}/logs",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetProvisioningLogs, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/clusters routes
 		r.Method(
 			"GET",
@@ -636,6 +669,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/{namespace}/ingress/{name}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetIngress, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/{kind}/status",

Некоторые файлы не были показаны из-за большого количества измененных файлов