Kaynağa Gözat

Merge branch 'beta.3.provisioner-backend' into main

sunguroku 5 yıl önce
ebeveyn
işleme
d250e34adf

+ 1 - 0
cmd/app/main.go

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

+ 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{},

+ 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;
   

+ 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

+ 4 - 2
go.mod

@@ -23,8 +23,10 @@ require (
 	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/v8 v8.4.4
 	github.com/go-test/deep v1.0.7
-	github.com/google/go-cmp v0.5.1
+	github.com/google/go-cmp v0.5.4
 	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
@@ -51,7 +53,7 @@ require (
 	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
 	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-20200930185726-fdedc70b468f
 	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

+ 14 - 0
go.sum

@@ -328,6 +328,8 @@ github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mz
 github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/digitalocean/godo v1.19.0/go.mod h1:AAPQ+tiM4st79QHlEBTg8LM7JQNre4SAQCbn56wEyKY=
 github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
@@ -495,6 +497,10 @@ github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD87
 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
 github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+b2xXj0AU1Es7o=
 github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
+github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
+github.com/go-redis/redis/v8 v8.4.4 h1:fGqgxCTR1sydaKI00oQf3OmkU/DIe/I/fYXvGklCIuc=
+github.com/go-redis/redis/v8 v8.4.4/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@@ -610,6 +616,8 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-containerregistry v0.1.4 h1:fZm+V2pYnvb8NMPM1YOsyxr31XKfpHTun5oVTRnG8qc=
 github.com/google/go-containerregistry v0.1.4/go.mod h1:6EGiuQp36pL82lX6rFN0s9AJOVL0Mlgx/DAsYZW5X3s=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
@@ -1074,6 +1082,7 @@ github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoT
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
+github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
 github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -1412,6 +1421,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opentelemetry.io/otel v0.15.0 h1:CZFy2lPhxd4HlhZnYK8gRyDotksO3Ip9rBweY1vVYJw=
+go.opentelemetry.io/otel v0.15.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
@@ -1549,6 +1560,8 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0 h1:wBouT66WTYFXdxfVdz9sVWARVd/2vfGcmI45D2gj45M=
 golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U=
+golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -1926,6 +1939,7 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
 k8s.io/api v0.16.8/go.mod h1:a8EOdYHO8en+YHhPBLiW5q+3RfHTr7wxTqqp7emJ7PM=
 k8s.io/api v0.18.8 h1:aIKUzJPb96f3fKec2lxtY7acZC9gQNDLVhfSGpxBAC4=
 k8s.io/api v0.18.8/go.mod h1:d/CXqwWv+Z2XEG1LgceeDmHQwpUJhROPx16SlxJgERY=
+k8s.io/api v0.20.1 h1:ud1c3W3YNzGd6ABJlbFfKXBKXO+1KdGfcgGGNgFR03E=
 k8s.io/apiextensions-apiserver v0.18.8 h1:pkqYPKTHa0/3lYwH7201RpF9eFm0lmZDFBNzhN+k/sA=
 k8s.io/apiextensions-apiserver v0.18.8/go.mod h1:7f4ySEkkvifIr4+BRrRWriKKIJjPyg9mb/p63dJKnlM=
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=

+ 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
+}

+ 71 - 1
internal/kubernetes/agent.go

@@ -7,9 +7,15 @@ import (
 	"io"
 	"strings"
 
+	"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"
@@ -122,7 +128,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
@@ -223,3 +229,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 = &provisioner.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
+}

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

@@ -0,0 +1,240 @@
+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"
+)
+
+// 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     *RedisConf
+	Postgres  *PostgresConf
+
+	// provider-specific configurations
+	AWS *aws.Conf
+	ECR *ecr.Conf
+	EKS *eks.Conf
+}
+
+// RedisConf is the redis config required for the provisioner container
+type RedisConf struct {
+	Host string
+	Port string
+}
+
+// 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
+}

+ 73 - 0
internal/models/infra.go

@@ -0,0 +1,73 @@
+package models
+
+import (
+	"fmt"
+
+	"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"
+	StatusUpdating InfraStatus = "updating"
+)
+
+// 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)
+}

+ 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"`

+ 40 - 16
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{},
@@ -435,3 +437,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)
+}

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

@@ -0,0 +1,64 @@
+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
+}

+ 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),

+ 12 - 0
internal/repository/infra.go

@@ -0,0 +1,12 @@
+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)
+}

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

@@ -0,0 +1,73 @@
+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
+}

+ 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

+ 230 - 0
server/api/provision_handler.go

@@ -0,0 +1,230 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	redis "github.com/go-redis/redis/v8"
+	"github.com/gorilla/websocket"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	prov "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 = StreamRedis(streamName, conn)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}
+
+// helper functions
+
+// StreamRedis performs an XREAD operation on the given stream and outputs it to the given websocket conn.
+func StreamRedis(streamName string, conn *websocket.Conn) error {
+	conf := &prov.RedisConf{
+		Host: "redis",
+		Port: "6379",
+	}
+
+	client, err := NewRedisClient(conf)
+
+	if err != nil {
+		return err
+	}
+
+	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
+		}
+	}
+}
+
+// NewRedisClient returns a new redis client instance
+func NewRedisClient(conf *prov.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
+}

+ 33 - 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",