Ver Fonte

Merge pull request #166 from porter-dev/beta.3.integration-frontend

Beta.3.integration frontend
jusrhee há 5 anos atrás
pai
commit
654ad40c5c
59 ficheiros alterados com 1920 adições e 196 exclusões
  1. 23 0
      .github/workflows/gcr.yaml
  2. 42 0
      .gitignore
  3. 37 0
      cli/cmd/api/registry.go
  4. 121 0
      cli/cmd/docker.go
  5. 2 0
      cli/cmd/docker/porter.go
  6. 144 46
      cli/cmd/github/release.go
  7. 3 3
      cli/cmd/registry.go
  8. 9 5
      cli/cmd/root.go
  9. 31 1
      cli/cmd/server.go
  10. 19 17
      cmd/app/main.go
  11. 196 0
      cmd/docker-credential-porter/helper/cache.go
  12. 150 0
      cmd/docker-credential-porter/helper/helper.go
  13. 29 0
      cmd/docker-credential-porter/main.go
  14. BIN
      dashboard/src/assets/135838220_453682579008540_3631330216063463363_n.jpg
  15. BIN
      dashboard/src/assets/135843858_160478529194149_2718194807911771299_n.jpg
  16. BIN
      dashboard/src/assets/My Health Connection - Appointment Details.pdf
  17. BIN
      dashboard/src/assets/rocket.png
  18. 0 1
      dashboard/src/components/repo-selector/RepoSelector.tsx
  19. 38 5
      dashboard/src/main/home/Home.tsx
  20. 4 4
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  21. 2 2
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  22. 2 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  23. 2 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  24. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  25. 179 0
      dashboard/src/main/home/dashboard/ClusterList.tsx
  26. 6 8
      dashboard/src/main/home/dashboard/Dashboard.tsx
  27. 118 0
      dashboard/src/main/home/dashboard/StatusPlaceholder.tsx
  28. 29 0
      dashboard/src/main/home/dashboard/StatusPlaceholderContainer.tsx
  29. 1 0
      dashboard/src/main/home/integrations/integration-form/ECRForm.tsx
  30. 3 16
      dashboard/src/main/home/navbar/Feedback.tsx
  31. 63 5
      dashboard/src/main/home/new-project/NewProject.tsx
  32. 91 4
      dashboard/src/main/home/new-project/Provisioner.tsx
  33. 3 3
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  34. 3 3
      dashboard/src/main/home/sidebar/Drawer.tsx
  35. 28 7
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  36. 1 1
      dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx
  37. 1 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  38. 2 2
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  39. 11 3
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  40. 3 3
      dashboard/src/shared/Context.tsx
  41. 25 0
      dashboard/src/shared/api.tsx
  42. 19 0
      dashboard/src/shared/feedback.tsx
  43. 8 1
      dashboard/src/shared/types.tsx
  44. 5 2
      go.mod
  45. 10 0
      go.sum
  46. 4 4
      internal/adapter/redis.go
  47. 8 2
      internal/config/redis.go
  48. 18 0
      internal/forms/infra.go
  49. 1 0
      internal/forms/registry.go
  50. 31 2
      internal/kubernetes/agent.go
  51. 87 7
      internal/kubernetes/provisioner/global_stream.go
  52. 2 1
      internal/kubernetes/provisioner/resource_stream.go
  53. 15 8
      internal/models/infra.go
  54. 7 0
      internal/models/registry.go
  55. 15 16
      server/api/api.go
  56. 40 0
      server/api/infra_handler.go
  57. 91 1
      server/api/provision_handler.go
  58. 104 0
      server/api/registry_handler.go
  59. 33 4
      server/router/router.go

+ 23 - 0
.github/workflows/gcr.yaml

@@ -0,0 +1,23 @@
+name: Build, Push to GCR.
+on:
+  push:
+    branches:
+      - staging
+jobs:
+  login-build-push:
+  runs-on: ubuntu-latest
+  steps:
+  - name: Set up Cloud SDK
+    uses: google-github-actions/setup-gcloud@master
+    with:
+      project_id: ${{ secrets.GCP_PROJECT_ID }}
+      service_account_key: ${{ secrets.GCP_SA_KEY }}
+      export_default_credentials: true
+  - name: Log in to gcloud CLI
+    run: gcloud auth configure-docker
+  - name: Checkout
+    uses: actions/checkout@v2.3.4
+  - name: Build
+    run: |
+      docker build . -t gcr.io/porter-dev-273614/porter-prov:latest -f ./docker/Dockerfile
+      docker push gcr.io/porter-dev-273614/porter-prov:latest

+ 42 - 0
.gitignore

@@ -9,3 +9,45 @@ gon.hcl
 internal/local_templates
 gon*.hcl
 *prod.Dockerfile
+
+# Local .terraform directories
+**/.terraform/*
+
+.terraform
+
+.terraform.lock.hcl
+
+*kubeconfig*
+
+# .tfstate files
+*.tfstate
+*.tfstate.*
+
+# Crash log files
+crash.log
+
+# Exclude all .tfvars files, which are likely to contain sentitive data, such as
+# password, private keys, and other secrets. These should not be part of version 
+# control as they are data points which are potentially sensitive and subject 
+# to change depending on the environment.
+#
+*.tfvars
+*.tfvars.json
+
+# Ignore override files as they are usually used to override resources locally and so
+# are not checked in
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+
+# Include override files you do wish to add to version control using negated pattern
+#
+# !example_override.tf
+
+# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
+# example: *tfplan*
+
+# Ignore CLI configuration files
+.terraformrc
+terraform.rc

+ 37 - 0
cli/cmd/api/registry.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"strings"
+	"time"
 
 	"github.com/porter-dev/porter/internal/registry"
 
@@ -165,6 +166,42 @@ func (c *Client) DeleteProjectRegistry(
 	return nil
 }
 
+// GetECRTokenResponse blah
+type GetECRTokenResponse struct {
+	Token     string     `json:"token"`
+	ExpiresAt *time.Time `json:"expires_at"`
+}
+
+// GetECRAuthorizationToken gets an ECR authorization token
+func (c *Client) GetECRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	region string,
+) (*GetECRTokenResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/ecr/%s/token", c.BaseURL, projectID, region),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bodyResp := &GetECRTokenResponse{}
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // ListRegistryRepositoryResponse is the list of repositories in a registry
 type ListRegistryRepositoryResponse []registry.Repository
 

+ 121 - 0
cli/cmd/docker.go

@@ -0,0 +1,121 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"path/filepath"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/github"
+	"github.com/spf13/cobra"
+
+	"github.com/docker/cli/cli/config/configfile"
+)
+
+var dockerCmd = &cobra.Command{
+	Use:   "docker",
+	Short: "Commands to configure Docker for a project",
+}
+
+var configureCmd = &cobra.Command{
+	Use:   "configure",
+	Short: "Configures the host's Docker instance",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, dockerConfig)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(dockerCmd)
+
+	dockerCmd.AddCommand(configureCmd)
+}
+
+func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	pID := getProjectID()
+
+	// get all registries that should be added
+	regToAdd := make([]string, 0)
+
+	// get the list of namespaces
+	registries, err := client.ListRegistries(
+		context.Background(),
+		pID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	for _, registry := range registries {
+		if registry.URL != "" {
+			// strip the protocol
+			regURL, err := url.Parse(registry.URL)
+
+			if err != nil {
+				continue
+			}
+
+			regToAdd = append(regToAdd, regURL.Host)
+		}
+	}
+
+	dockerConfigFile := filepath.Join(home, ".docker", "config.json")
+
+	// determine if configfile exists
+	if info, err := os.Stat(dockerConfigFile); info.IsDir() || os.IsNotExist(err) {
+		// if it does not exist, create it
+		err := ioutil.WriteFile(dockerConfigFile, []byte("{}"), 0700)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// read the file bytes
+	configBytes, err := ioutil.ReadFile(dockerConfigFile)
+
+	if err != nil {
+		return err
+	}
+
+	// download the porter cred helper
+	z := &github.ZIPReleaseGetter{
+		AssetName:           "docker-credential-porter",
+		AssetFolderDest:     "/usr/local/bin",
+		ZipFolderDest:       filepath.Join(home, ".porter"),
+		ZipName:             "docker-credential-porter_latest.zip",
+		EntityID:            "porter-dev",
+		RepoName:            "porter",
+		IsPlatformDependent: true,
+	}
+
+	err = z.GetLatestRelease()
+
+	if err != nil {
+		return err
+	}
+
+	config := &configfile.ConfigFile{
+		Filename: dockerConfigFile,
+	}
+
+	err = json.Unmarshal(configBytes, config)
+
+	if err != nil {
+		return err
+	}
+
+	for _, regURL := range regToAdd {
+		config.CredentialHelpers[regURL] = "porter"
+	}
+
+	return config.Save()
+}

+ 2 - 0
cli/cmd/docker/porter.go

@@ -132,6 +132,8 @@ func StartPorter(opts *PorterStartOpts) (agent *Agent, id string, err error) {
 		}...)
 	}
 
+	opts.Env = append(opts.Env, "REDIS_ENABLED=false")
+
 	// create Porter container
 	startOpts := PorterServerStartOpts{
 		Name:          "porter_server_" + opts.ProcessID,

+ 144 - 46
cli/cmd/github/release.go

@@ -15,85 +15,183 @@ import (
 	"github.com/google/go-github/github"
 )
 
-func getLatestReleaseDownloadURL() (string, string, error) {
-	client := github.NewClient(nil)
+// ZIPReleaseGetter retrieves a release from Github in ZIP format and downloads it
+// to a directory on host
+type ZIPReleaseGetter struct {
+	// The name of the asset, i.e. "porter", "portersvr", "static"
+	AssetName string
+
+	// The host folder destination of the asset
+	AssetFolderDest string
+
+	// The host folder destination for the .zip file
+	ZipFolderDest string
+
+	// The name of the .zip file to download to
+	ZipName string
+
+	// The name of the Github entity whose repo is queried: i.e. "porter-dev"
+	EntityID string
+
+	// The name of the Github repo to get releases from
+	RepoName string
+
+	// If the asset is platform dependent
+	IsPlatformDependent bool
+}
+
+// GetLatestRelease downloads the latest .zip release from a given Github repository
+func (z *ZIPReleaseGetter) GetLatestRelease() error {
+	releaseURL, err := z.getLatestReleaseDownloadURL()
+
+	if err != nil {
+		return err
+	}
+
+	return z.getReleaseFromURL(releaseURL)
+}
+
+// GetRelease downloads a specific .zip release from a given Github repository
+func (z *ZIPReleaseGetter) GetRelease(releaseTag string) error {
+	releaseURL, err := z.getReleaseDownloadURL(releaseTag)
+
+	fmt.Printf("getting release %s\n", releaseURL)
 
-	rel, _, err := client.Repositories.GetLatestRelease(context.Background(), "porter-dev", "porter")
+	if err != nil {
+		return err
+	}
+
+	return z.getReleaseFromURL(releaseURL)
+}
+
+func (z *ZIPReleaseGetter) getReleaseFromURL(releaseURL string) error {
+	fmt.Printf("getting release %s\n", releaseURL)
+
+	err := z.downloadToFile(releaseURL)
+
+	fmt.Printf("downloaded release %s to file %s\n", z.AssetName, filepath.Join(z.ZipFolderDest, z.ZipName))
 
 	if err != nil {
-		return "", "", err
+		return err
 	}
 
-	var re *regexp.Regexp
+	fmt.Printf("unzipping %s to %s\n", z.AssetName, z.AssetFolderDest)
 
-	switch os := runtime.GOOS; os {
-	case "darwin":
-		re = regexp.MustCompile(`portersvr_.*_Darwin_x86_64\.zip`)
-	case "linux":
-		re = regexp.MustCompile(`portersvr_.*_Linux_x86_64\.zip`)
-	default:
-		fmt.Printf("%s.\n", os)
+	err = z.unzipToDir()
+
+	return err
+}
+
+// retrieves the download url for the latest release of an asset
+func (z *ZIPReleaseGetter) getLatestReleaseDownloadURL() (string, error) {
+	client := github.NewClient(nil)
+
+	rel, _, err := client.Repositories.GetLatestRelease(context.Background(), z.EntityID, z.RepoName)
+
+	if err != nil {
+		return "", err
 	}
 
-	staticRE := regexp.MustCompile(`static_.*\.zip`)
+	re, err := z.getDownloadRegexp()
+
+	if err != nil {
+		return "", err
+	}
 
 	releaseURL := ""
-	staticReleaseURL := ""
 
 	// iterate through the assets
 	for _, asset := range rel.Assets {
 		if downloadURL := asset.GetBrowserDownloadURL(); re.MatchString(downloadURL) {
 			releaseURL = downloadURL
-		} else if staticRE.MatchString(downloadURL) {
-			staticReleaseURL = downloadURL
 		}
 	}
 
-	return releaseURL, staticReleaseURL, nil
+	return releaseURL, nil
 }
 
-// DownloadLatestServerRelease retrieves the latest Porter server release from Github, unzips
-// it, and adds the binary to the porter directory
-func DownloadLatestServerRelease(porterDir string) error {
-	releaseURL, staticReleaseURL, err := getLatestReleaseDownloadURL()
-	fmt.Println(releaseURL)
+func (z *ZIPReleaseGetter) getReleaseDownloadURL(releaseTag string) (string, error) {
+	client := github.NewClient(nil)
+
+	rel, _, err := client.Repositories.GetReleaseByTag(context.Background(), z.EntityID, z.RepoName, releaseTag)
 
 	if err != nil {
-		return err
+		return "", fmt.Errorf("release %s does not exist", releaseTag)
 	}
 
-	zipFile := filepath.Join(porterDir, "portersrv_latest.zip")
-
-	err = downloadToFile(releaseURL, zipFile)
+	re, err := z.getDownloadRegexp()
 
 	if err != nil {
-		return err
+		return "", err
 	}
 
-	err = unzipToDir(zipFile, porterDir)
+	releaseURL := ""
 
-	if err != nil {
-		return err
+	// iterate through the assets
+	for _, asset := range rel.Assets {
+		if downloadURL := asset.GetBrowserDownloadURL(); re.MatchString(downloadURL) {
+			releaseURL = downloadURL
+		}
 	}
 
-	staticZipFile := filepath.Join(porterDir, "static_latest.zip")
-
-	err = downloadToFile(staticReleaseURL, staticZipFile)
+	return releaseURL, nil
+}
 
-	if err != nil {
-		return err
+func (z *ZIPReleaseGetter) getDownloadRegexp() (*regexp.Regexp, error) {
+	if z.IsPlatformDependent {
+		switch os := runtime.GOOS; os {
+		case "darwin":
+			return regexp.MustCompile(fmt.Sprintf(`(?i)%s_.*_Darwin_x86_64\.zip`, z.AssetName)), nil
+		case "linux":
+			return regexp.MustCompile(fmt.Sprintf(`(?i)%s_.*_Linux_x86_64\.zip`, z.AssetName)), nil
+		default:
+			return nil, fmt.Errorf("%s is not a supported platform for Porter binaries", os)
+		}
 	}
 
-	staticDir := filepath.Join(porterDir, "static")
+	return regexp.MustCompile(fmt.Sprintf(`(?i)%s_.*\.zip`, z.AssetName)), nil
+}
 
-	err = unzipToDir(staticZipFile, staticDir)
+// // DownloadLatestServerRelease retrieves the latest Porter server release from Github, unzips
+// // it, and adds the binary to the porter directory
+// func DownloadLatestServerRelease(porterDir string) error {
+// 	releaseURL, staticReleaseURL, err := getLatestReleaseDownloadURL()
+// 	fmt.Println(releaseURL)
 
-	return err
-}
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	zipFile := filepath.Join(porterDir, "portersrv_latest.zip")
+
+// 	err = downloadToFile(releaseURL, zipFile)
+
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	err = unzipToDir(zipFile, porterDir)
+
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	staticZipFile := filepath.Join(porterDir, "static_latest.zip")
+
+// 	err = downloadToFile(staticReleaseURL, staticZipFile)
+
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	staticDir := filepath.Join(porterDir, "static")
+
+// 	err = unzipToDir(staticZipFile, staticDir)
 
-func downloadToFile(url string, filepath string) error {
-	fmt.Println("Downloading:", url)
+// 	return err
+// }
 
+func (z *ZIPReleaseGetter) downloadToFile(url string) error {
 	// Get the data
 	resp, err := http.Get(url)
 
@@ -104,7 +202,7 @@ func downloadToFile(url string, filepath string) error {
 	defer resp.Body.Close()
 
 	// Create the file
-	out, err := os.Create(filepath)
+	out, err := os.Create(filepath.Join(z.ZipFolderDest, z.ZipName))
 
 	if err != nil {
 		return err
@@ -118,8 +216,8 @@ func downloadToFile(url string, filepath string) error {
 	return err
 }
 
-func unzipToDir(zipfile string, dir string) error {
-	r, err := zip.OpenReader(zipfile)
+func (z *ZIPReleaseGetter) unzipToDir() error {
+	r, err := zip.OpenReader(filepath.Join(z.ZipFolderDest, z.ZipName))
 
 	if err != nil {
 		return err
@@ -129,10 +227,10 @@ func unzipToDir(zipfile string, dir string) error {
 
 	for _, f := range r.File {
 		// Store filename/path for returning and using later on
-		fpath := filepath.Join(dir, f.Name)
+		fpath := filepath.Join(z.AssetFolderDest, f.Name)
 
 		// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
-		if !strings.HasPrefix(fpath, filepath.Clean(dir)+string(os.PathSeparator)) {
+		if !strings.HasPrefix(fpath, filepath.Clean(z.AssetFolderDest)+string(os.PathSeparator)) {
 			return fmt.Errorf("%s: illegal file path", fpath)
 		}
 

+ 3 - 3
cli/cmd/registry.go

@@ -120,15 +120,15 @@ func listRegistries(user *api.AuthCheckResponse, client *api.Client, args []stri
 	w := new(tabwriter.Writer)
 	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
 
-	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "SERVICE")
+	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "URL", "SERVICE")
 
 	currRegistryID := getRegistryID()
 
 	for _, registry := range registries {
 		if currRegistryID == registry.ID {
-			color.New(color.FgGreen).Fprintf(w, "%d\t%s\t%s (current registry)\n", registry.ID, registry.Name, registry.Service)
+			color.New(color.FgGreen).Fprintf(w, "%d\t%s\t%s (current registry)\n", registry.ID, registry.URL, registry.Service)
 		} else {
-			fmt.Fprintf(w, "%d\t%s\t%s\n", registry.ID, registry.Name, registry.Service)
+			fmt.Fprintf(w, "%d\t%s\t%s\n", registry.ID, registry.URL, registry.Service)
 		}
 	}
 

+ 9 - 5
cli/cmd/root.go

@@ -23,6 +23,15 @@ var home = homedir.HomeDir()
 // Execute adds all child commands to the root command and sets flags appropriately.
 // This is called by main.main(). It only needs to happen once to the rootCmd.
 func Execute() {
+	Setup()
+
+	if err := rootCmd.Execute(); err != nil {
+		color.New(color.FgRed).Println(err)
+		os.Exit(1)
+	}
+}
+
+func Setup() {
 	// check that the .porter folder exists; create if not
 	porterDir := filepath.Join(home, ".porter")
 
@@ -54,9 +63,4 @@ func Execute() {
 			os.Exit(1)
 		}
 	}
-
-	if err := rootCmd.Execute(); err != nil {
-		color.New(color.FgRed).Println(err)
-		os.Exit(1)
-	}
 }

+ 31 - 1
cli/cmd/server.go

@@ -175,7 +175,7 @@ func startLocal(
 	staticFilePath := filepath.Join(home, ".porter", "static")
 
 	if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
-		err := github.DownloadLatestServerRelease(porterDir)
+		err := downloadLatestReleases(porterDir)
 
 		if err != nil {
 			color.New(color.FgRed).Println("Failed:", err.Error())
@@ -224,3 +224,33 @@ func stopDocker() error {
 
 	return nil
 }
+
+func downloadLatestReleases(porterDir string) error {
+	z := &github.ZIPReleaseGetter{
+		AssetName:           "portersvr",
+		AssetFolderDest:     porterDir,
+		ZipFolderDest:       porterDir,
+		ZipName:             "portersvr_latest.zip",
+		EntityID:            "porter-dev",
+		RepoName:            "porter",
+		IsPlatformDependent: true,
+	}
+
+	err := z.GetLatestRelease()
+
+	if err != nil {
+		return err
+	}
+
+	zStatic := &github.ZIPReleaseGetter{
+		AssetName:           "static",
+		AssetFolderDest:     filepath.Join(porterDir, "static"),
+		ZipFolderDest:       porterDir,
+		ZipName:             "static_latest.zip",
+		EntityID:            "porter-dev",
+		RepoName:            "porter",
+		IsPlatformDependent: false,
+	}
+
+	return zStatic.GetLatestRelease()
+}

+ 19 - 17
cmd/app/main.go

@@ -30,14 +30,6 @@ 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{},
@@ -75,11 +67,26 @@ func main() {
 
 	repo := gorm.NewRepository(db, &key)
 
+	if appConf.Redis.Enabled {
+		redis, err := adapter.NewRedisClient(&appConf.Redis)
+
+		if err != nil {
+			logger.Fatal().Err(err).Msg("")
+			return
+		}
+
+		prov.InitGlobalStream(redis)
+
+		errorChan := make(chan error)
+
+		go prov.GlobalStreamListener(redis, *repo, errorChan)
+	}
+
 	a, _ := api.New(&api.AppConfig{
-		Logger:      logger,
-		Repository:  repo,
-		ServerConf:  appConf.Server,
-		RedisClient: redis,
+		Logger:     logger,
+		Repository: repo,
+		ServerConf: appConf.Server,
+		RedisConf:  &appConf.Redis,
 	})
 
 	appRouter := router.New(a)
@@ -96,12 +103,7 @@ 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)
 	}
-
 }

+ 196 - 0
cmd/docker-credential-porter/helper/cache.go

@@ -0,0 +1,196 @@
+package helper
+
+import (
+	"crypto/md5"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws/credentials"
+	"k8s.io/client-go/util/homedir"
+)
+
+type CredentialsCache interface {
+	Get(registry string) *AuthEntry
+	Set(registry string, entry *AuthEntry)
+	List() []*AuthEntry
+	Clear()
+}
+
+type AuthEntry struct {
+	AuthorizationToken string
+	RequestedAt        time.Time
+	ExpiresAt          time.Time
+	ProxyEndpoint      string
+}
+
+// IsValid checks if AuthEntry is still valid at testTime. AuthEntries expire at 1/2 of their original
+// requested window.
+func (authEntry *AuthEntry) IsValid(testTime time.Time) bool {
+	validWindow := authEntry.ExpiresAt.Sub(authEntry.RequestedAt)
+	refreshTime := authEntry.ExpiresAt.Add(-1 * validWindow / time.Duration(2))
+	return testTime.Before(refreshTime)
+}
+
+func BuildCredentialsCache(region string) CredentialsCache {
+	home := homedir.HomeDir()
+	cacheDir := filepath.Join(home, ".porter")
+	cacheFilename := "cache.json"
+
+	return NewFileCredentialsCache(cacheDir, cacheFilename, region)
+}
+
+// Determine a key prefix for a credentials cache. Because auth tokens are scoped to an account and region, rely on provided
+// region, as well as hash of the access key.
+func credentialsCachePrefix(region string, credentials *credentials.Value) string {
+	return fmt.Sprintf("%s-%s-", region, checksum(credentials.AccessKeyID))
+}
+
+// Base64 encodes an MD5 checksum. Relied on for uniqueness, and not for cryptographic security.
+func checksum(text string) string {
+	hasher := md5.New()
+	data := hasher.Sum([]byte(text))
+	return base64.StdEncoding.EncodeToString(data)
+}
+
+const registryCacheVersion = "1.0"
+
+type RegistryCache struct {
+	Registries map[string]*AuthEntry
+	Version    string
+}
+
+type fileCredentialCache struct {
+	path           string
+	filename       string
+	cachePrefixKey string
+}
+
+func newRegistryCache() *RegistryCache {
+	return &RegistryCache{
+		Registries: make(map[string]*AuthEntry),
+		Version:    registryCacheVersion,
+	}
+}
+
+// NewFileCredentialsCache returns a new file credentials cache.
+//
+// path is used for temporary files during save, and filename should be a relative filename
+// in the same directory where the cache is serialized and deserialized.
+//
+// cachePrefixKey is used for scoping credentials for a given credential cache (i.e. region and
+// accessKey).
+func NewFileCredentialsCache(path string, filename string, cachePrefixKey string) CredentialsCache {
+	if _, err := os.Stat(path); err != nil {
+		os.MkdirAll(path, 0700)
+	}
+
+	return &fileCredentialCache{path: path, filename: filename, cachePrefixKey: cachePrefixKey}
+}
+
+func (f *fileCredentialCache) Get(registry string) *AuthEntry {
+	registryCache := f.init()
+
+	return registryCache.Registries[f.cachePrefixKey+registry]
+}
+
+func (f *fileCredentialCache) Set(registry string, entry *AuthEntry) {
+	registryCache := f.init()
+
+	registryCache.Registries[f.cachePrefixKey+registry] = entry
+
+	f.save(registryCache)
+}
+
+// List returns all of the available AuthEntries (regardless of prefix)
+func (f *fileCredentialCache) List() []*AuthEntry {
+	registryCache := f.init()
+
+	// optimize allocation for copy
+	entries := make([]*AuthEntry, 0, len(registryCache.Registries))
+
+	for _, entry := range registryCache.Registries {
+		entries = append(entries, entry)
+	}
+
+	return entries
+}
+
+func (f *fileCredentialCache) Clear() {
+	os.Remove(f.fullFilePath())
+}
+
+func (f *fileCredentialCache) fullFilePath() string {
+	return filepath.Join(f.path, f.filename)
+}
+
+// Saves credential cache to disk. This writes to a temporary file first, then moves the file to the config location.
+// This eliminates from reading partially written credential files, and reduces (but does not eliminate) concurrent
+// file access. There is not guarantee here for handling multiple writes at once since there is no out of process locking.
+func (f *fileCredentialCache) save(registryCache *RegistryCache) error {
+	file, err := ioutil.TempFile(f.path, ".config.json.tmp")
+	if err != nil {
+		return err
+	}
+
+	buff, err := json.MarshalIndent(registryCache, "", "  ")
+	if err != nil {
+		file.Close()
+		os.Remove(file.Name())
+		return err
+	}
+
+	_, err = file.Write(buff)
+
+	if err != nil {
+		file.Close()
+		os.Remove(file.Name())
+		return err
+	}
+
+	file.Close()
+	// note this is only atomic when relying on linux syscalls
+	os.Rename(file.Name(), f.fullFilePath())
+	return err
+}
+
+func (f *fileCredentialCache) init() *RegistryCache {
+	registryCache, err := f.load()
+	if err != nil {
+		f.Clear()
+		registryCache = newRegistryCache()
+	}
+	return registryCache
+}
+
+// Loading a cache from disk will return errors for malformed or incompatible cache files.
+func (f *fileCredentialCache) load() (*RegistryCache, error) {
+	registryCache := newRegistryCache()
+
+	file, err := os.Open(f.fullFilePath())
+	if os.IsNotExist(err) {
+		return registryCache, nil
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer file.Close()
+
+	if err = json.NewDecoder(file).Decode(&registryCache); err != nil {
+		return nil, err
+	}
+
+	if registryCache.Version != registryCacheVersion {
+		return nil, fmt.Errorf("ecr: Registry cache version %#v is not compatible with %#v, ignoring existing cache",
+			registryCache.Version,
+			registryCacheVersion)
+	}
+
+	return registryCache, nil
+}

+ 150 - 0
cmd/docker-credential-porter/helper/helper.go

@@ -0,0 +1,150 @@
+package helper
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/docker/docker-credential-helpers/credentials"
+	"github.com/porter-dev/porter/cli/cmd"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/viper"
+	"k8s.io/client-go/util/homedir"
+)
+
+// PorterHelper implements credentials.Helper: it acts as a credentials
+// helper for Docker that allows authentication with different registries.
+type PorterHelper struct {
+	Debug bool
+
+	credCache CredentialsCache
+}
+
+// Add appends credentials to the store.
+func (p *PorterHelper) Add(cr *credentials.Credentials) error {
+	// Doesn't seem to be called
+	return nil
+}
+
+// Delete removes credentials from the store.
+func (p *PorterHelper) Delete(serverURL string) error {
+	// Doesn't seem to be called
+	return nil
+}
+
+var ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?`)
+
+// Get retrieves credentials from the store.
+// It returns username and secret as strings.
+func (p *PorterHelper) Get(serverURL string) (user string, secret string, err error) {
+	// parse the server url for region
+	matches := ecrPattern.FindStringSubmatch(serverURL)
+
+	if len(matches) == 0 {
+		err := fmt.Errorf("only ECR registry URLs are supported")
+
+		if p.Debug {
+			log.Printf("Error: %s\n", err.Error())
+		}
+
+		return "", "", err
+	} else if len(matches) < 3 {
+		err := fmt.Errorf("%s is not a valid ECR repository URI", serverURL)
+
+		if p.Debug {
+			log.Printf("Error: %s\n", err.Error())
+		}
+
+		return "", "", err
+	}
+
+	region := matches[3]
+
+	credCache := BuildCredentialsCache(region)
+	cachedEntry := credCache.Get(serverURL)
+
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		host := viper.GetString("host")
+		projID := viper.GetUint("project")
+
+		client := api.NewClient(host+"/api", "cookie.json")
+
+		// get a token from the server
+		tokenResp, err := client.GetECRAuthorizationToken(context.Background(), projID, matches[3])
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		credCache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return p.getAuth(token)
+}
+
+// List returns the stored serverURLs and their associated usernames.
+func (p *PorterHelper) List() (map[string]string, error) {
+	credCache := BuildCredentialsCache("")
+	entries := credCache.List()
+
+	res := make(map[string]string)
+
+	for _, entry := range entries {
+		user, _, err := p.getAuth(entry.AuthorizationToken)
+
+		if err != nil {
+			continue
+		}
+
+		res[entry.ProxyEndpoint] = user
+	}
+
+	return res, nil
+}
+
+func (p *PorterHelper) getAuth(token string) (string, string, error) {
+	decodedToken, err := base64.StdEncoding.DecodeString(token)
+
+	if err != nil {
+		return "", "", fmt.Errorf("Invalid token: %v", err)
+	}
+
+	parts := strings.SplitN(string(decodedToken), ":", 2)
+
+	if len(parts) < 2 {
+		return "", "", fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
+	}
+
+	return parts[0], parts[1], nil
+}
+
+func (p *PorterHelper) init() {
+	cmd.Setup()
+
+	if p.Debug {
+		var home = homedir.HomeDir()
+		file, err := os.OpenFile(filepath.Join(home, ".porter", "logs.txt"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
+
+		if err == nil {
+			log.SetOutput(file)
+		}
+	}
+}

+ 29 - 0
cmd/docker-credential-porter/main.go

@@ -0,0 +1,29 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+
+	"github.com/docker/docker-credential-helpers/credentials"
+	"github.com/porter-dev/porter/cmd/docker-credential-porter/helper"
+)
+
+// Version will be linked by an ldflag during build
+var Version string = "dev"
+
+func main() {
+	var versionFlag bool
+	flag.BoolVar(&versionFlag, "version", false, "print version and exit")
+	flag.Parse()
+
+	// Exit safely when version is used
+	if versionFlag {
+		fmt.Println(Version)
+		os.Exit(0)
+	}
+
+	credentials.Serve(&helper.PorterHelper{
+		Debug: Version == "dev",
+	})
+}

BIN
dashboard/src/assets/135838220_453682579008540_3631330216063463363_n.jpg


BIN
dashboard/src/assets/135843858_160478529194149_2718194807911771299_n.jpg


BIN
dashboard/src/assets/My Health Connection - Appointment Details.pdf


BIN
dashboard/src/assets/rocket.png


+ 0 - 1
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -45,7 +45,6 @@ export default class RepoSelector extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
-        console.log(res.data);
         this.setState({ repos: res.data, loading: false, error: false });
       }
     });

+ 38 - 5
dashboard/src/main/home/Home.tsx

@@ -4,7 +4,7 @@ import ReactModal from 'react-modal';
 
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
-import { ProjectType } from '../../shared/types';
+import { InfraType } from '../../shared/types';
 
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
@@ -28,6 +28,7 @@ type StateType = {
   forceSidebar: boolean,
   showWelcome: boolean,
   currentView: string,
+  viewData: any,
 
   // Track last project id for refreshing clusters on project change
   prevProjectId: number | null,
@@ -39,6 +40,7 @@ export default class Home extends Component<PropsType, StateType> {
     showWelcome: false,
     currentView: 'dashboard',
     prevProjectId: null as number | null,
+    viewData: null as any
   }
 
   // Possibly consolidate into context (w/ ProjectSection + NewProject)
@@ -46,11 +48,29 @@ export default class Home extends Component<PropsType, StateType> {
     let { user, currentProject, projects, setProjects } = this.context;
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
       if (err) {
-        console.log(err)
+        console.log(err);
       } else if (res.data) {
         setProjects(res.data);
         if (res.data.length > 0 && !currentProject) {
           this.context.setCurrentProject(res.data[0]);
+
+          // Check if current project is provisioning
+          api.getInfra('<token>', {}, { project_id: res.data[0].id }, (err: any, res: any) => {
+            if (err) {
+              console.log(err);
+            } else if (res.data) {
+              
+              // TODO: separately handle non meta-provisioning case
+              res.data.forEach((el: InfraType) => {
+                if (el.status === 'creating') {
+                  this.setState({ currentView: 'provisioner', viewData: {
+                    infra_id: el.id,
+                    kind: el.kind,
+                  }});
+                }
+              });
+            }
+          });
         } else if (res.data.length === 0) {
           this.setState({ currentView: 'new-project' });
         }
@@ -122,10 +142,15 @@ export default class Home extends Component<PropsType, StateType> {
       return <Integrations />;
     } else if (currentView === 'new-project') {
       return (
-        <NewProject setCurrentView={(x: string) => this.setState({ currentView: x })} />
+        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x, viewData: data })} />
       );
     } else if (currentView === 'provisioner') {
-      return <Provisioner />
+      return (
+        <Provisioner 
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
+          viewData={this.state.viewData}
+        />
+      );
     }
 
     return (
@@ -135,6 +160,14 @@ export default class Home extends Component<PropsType, StateType> {
     );
   }
 
+  setCurrentView = (x: string, viewData?: any) => {
+    if (!viewData) {
+      this.setState({ currentView: x });
+    } else {
+      this.setState({ currentView: x, viewData });
+    }
+  }
+
   renderSidebar = () => {
     if (this.context.projects.length > 0) {
 
@@ -147,7 +180,7 @@ export default class Home extends Component<PropsType, StateType> {
         <Sidebar
           forceSidebar={this.state.forceSidebar}
           setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
-          setCurrentView={(x: string) => this.setState({ currentView: x })}
+          setCurrentView={this.setCurrentView}
           currentView={this.state.currentView}
         />
       );

+ 4 - 4
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
 import { Context } from '../../../shared/Context';
-import { ChartType, StorageType, Cluster } from '../../../shared/types';
+import { ChartType, StorageType, ClusterType } from '../../../shared/types';
 import api from '../../../shared/api';
 
 import ChartList from './chart/ChartList';
@@ -11,7 +11,7 @@ import NamespaceSelector from './NamespaceSelector';
 import ExpandedChart from './expanded-chart/ExpandedChart';
 
 type PropsType = {
-  currentCluster: Cluster,
+  currentCluster: ClusterType,
   setSidebar: (x: boolean) => void
   setCurrentView: (x: string) => void,
 };
@@ -212,10 +212,10 @@ const ButtonAlt = styled(Button)`
 `;
 
 const LineBreak = styled.div`
-  width: calc(100% - 180px);
+  width: calc(100% - 0px);
   height: 2px;
   background: #ffffff20;
-  margin: 10px 80px 35px;
+  margin: 10px 0px 35px;
 `;
 
 const Overlay = styled.div`

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -3,13 +3,13 @@ import styled from 'styled-components';
 
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
-import { ChartType, StorageType, Cluster } from '../../../../shared/types';
+import { ChartType, StorageType, ClusterType } from '../../../../shared/types';
 
 import Chart from './Chart';
 import Loading from '../../../../components/Loading';
 
 type PropsType = {
-  currentCluster: Cluster,
+  currentCluster: ClusterType,
   namespace: string,
   setCurrentChart: (c: ChartType) => void
 };

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

@@ -4,7 +4,7 @@ import yaml from 'js-yaml';
 import close from '../../../../assets/close.png';
 import _ from 'lodash';
 
-import { ResourceType, ChartType, StorageType, Cluster } from '../../../../shared/types';
+import { ResourceType, ChartType, StorageType, ClusterType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
 
@@ -23,7 +23,7 @@ import { format } from 'util';
 type PropsType = {
   namespace: string,
   currentChart: ChartType,
-  currentCluster: Cluster,
+  currentCluster: ClusterType,
   setCurrentChart: (x: ChartType | null) => void,
   setSidebar: (x: boolean) => void,
   setCurrentView: (x: string) => void,
@@ -438,8 +438,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       this.props.currentChart 
     );
 
-    console.log(this.props.currentChart.name)
-
     api.getIngress('<token>', { 
       cluster_id: currentCluster.id,
     }, {

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

@@ -117,13 +117,12 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       >
         {
           this.state.raw.map((pod, i) => {
-            console.log('pod', pod)
-            let status = this.getPodStatus(pod.status)
+            let status = this.getPodStatus(pod.status);
             return (
               <Tab 
                 key={pod.metadata?.name}
                 selected={selectedPod?.metadata?.name === pod?.metadata?.name}
-                onClick={() => {selectPod(pod)}}
+                onClick={() => { selectPod(pod)} }
               > 
                 <Gutter>
                   <Rail />

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

+ 179 - 0
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -0,0 +1,179 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+import { ClusterType } from '../../../shared/types';
+import Helper from '../../../components/values-form/Helper';
+
+import Loading from '../../../components/Loading';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+};
+
+type StateType = {
+  loading: boolean,
+  error: string,
+  clusters: ClusterType[],
+};
+
+export default class Templates extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: '',
+    clusters: [] as ClusterType[],
+  }
+
+  componentDidMount() {
+    api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
+      if (res && res.data) {
+        this.setState({ clusters: res.data, loading: false, error: '' });
+      } else {
+        this.setState({ loading: false, error: err });
+      }
+    });
+  }
+
+  renderIcon = () => {
+    return (
+      <DashboardIcon><i className="material-icons">device_hub</i></DashboardIcon>
+    );
+  }
+
+  renderClusters = () => {
+    return this.state.clusters.map((cluster: ClusterType, i: number) => {
+      return (
+        <TemplateBlock 
+          onClick={() => { 
+            this.context.setCurrentCluster(cluster); 
+            this.props.setCurrentView('cluster-dashboard');
+          }}
+          key={i}
+        >
+          {this.renderIcon()}
+          <TemplateTitle>
+            {cluster.name}
+          </TemplateTitle>
+        </TemplateBlock>
+      );
+    });
+  }
+  
+  render() {
+    return (
+      <>
+        <Helper>Clusters connected to this project:</Helper>
+        <TemplateList>
+          {this.renderClusters()}
+        </TemplateList>
+      </>
+    );
+  }
+}
+
+Templates.contextType = Context;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676C7C;
+  border: 2px solid #8e94aa;
+
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TemplateTitle = styled.div`
+  margin-bottom: 26px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  border: 1px solid #ffffff00;
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 35px 10px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 165px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 5px 8px 0px #00000033;
+  :hover {
+    background: #ffffff11;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const TemplateList = styled.div`
+  overflow-y: auto;
+  margin-top: 32px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 18px;
+      color: #858FAAaa;
+      cursor: pointer;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;
+
+const TemplatesWrapper = styled.div`
+  width: calc(90% - 150px);
+  min-width: 300px;
+  padding-top: 50px;
+`;

+ 6 - 8
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
 import { Context } from '../../../shared/Context';
-import PipelinesSection from './PipelinesSection';
+import StatusPlaceholderContainer from './StatusPlaceholderContainer';
 
 type PropsType = {
   setCurrentView: (x: string) => void,
@@ -53,9 +53,7 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
           <LineBreak />
 
-          <Placeholder>
-            🚀 Pipelines coming soon.
-          </Placeholder>
+          <StatusPlaceholderContainer setCurrentView={this.props.setCurrentView} />
         </div>
       );
     }
@@ -63,9 +61,9 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <div>
+      <>
         {this.renderContents()}
-      </div>
+      </>
     );
   }
 }
@@ -172,10 +170,10 @@ const ButtonAlt = styled(Button)`
 `;
 
 const LineBreak = styled.div`
-  width: calc(100% - 180px);
+  width: calc(100% - 0px);
   height: 2px;
   background: #ffffff20;
-  margin: 10px 80px 35px;
+  margin: 10px 0px 35px;
 `;
 
 const Overlay = styled.div`

+ 118 - 0
dashboard/src/main/home/dashboard/StatusPlaceholder.tsx

@@ -0,0 +1,118 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ClusterType } from '../../../shared/types';
+
+import ClusterList from './ClusterList';
+import Loading from '../../../components/Loading';
+
+type PropsType = {
+  currentCluster: ClusterType,
+  setCurrentView: (x: string) => void,
+};
+
+type StateType = {
+  loading: boolean,
+};
+
+export default class StatusPlaceholder extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+  }
+
+  componentDidMount() {
+    setTimeout(() => {
+      this.setState({ loading: false });
+    }, 100);
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentCluster !== this.props.currentCluster) {
+      this.setState({ loading: false });
+    }
+  }
+
+  render() {
+    if (this.state.loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (!this.props.currentCluster) {
+      return (
+        <>
+          <Banner>
+            <i className="material-icons">error_outline</i>
+            This project currently has no clusters connected.
+          </Banner>
+          <StyledStatusPlaceholder>
+            <Highlight onClick={() => {
+              this.context.setCurrentModal('ClusterInstructionsModal', {});
+            }}>
+              + Add a Cluster
+            </Highlight>
+          </StyledStatusPlaceholder>
+        </>
+      );
+    } else {
+      return (
+        <ClusterList setCurrentView={this.props.setCurrentView} />
+      );
+    }
+  }
+}
+
+StatusPlaceholder.contextType = Context;
+
+const LoadingWrapper = styled.div`
+  height: calc(100vh - 450px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Highlight = styled.div`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: underline;
+  margin-left: 10px;
+  margin-right: 10px;
+`;
+
+const Banner = styled.div`
+  height: 40px;
+  width: 100%;
+  margin: 15px 0;
+  font-size: 13px;
+  display: flex;
+  border-radius: 5px;
+  padding-left: 15px;
+  align-items: center;
+  background: #616FEEcc;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+`;
+
+const StyledStatusPlaceholder = styled.div`
+  width: 100%;
+  height: calc(100vh - 450px);
+  margin-top: 30px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb;
+  border-radius: 5px;
+  text-align: center;
+  font-size: 13px;
+  padding-bottom: 25px;
+  background: #ffffff09;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Work Sans', sans-serif;
+  user-select: text;
+`;

+ 29 - 0
dashboard/src/main/home/dashboard/StatusPlaceholderContainer.tsx

@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import StatusPlaceholder from './StatusPlaceholder';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+};
+
+type StateType = {
+};
+
+// Props in context to project section to trigger update on context change
+export default class StatusPlaceholderContainer extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    return (
+      <StatusPlaceholder
+        setCurrentView={this.props.setCurrentView}
+        currentCluster={this.context.currentCluster}
+      />
+    );
+  }
+}
+
+StatusPlaceholderContainer.contextType = Context;

+ 1 - 0
dashboard/src/main/home/integrations/integration-form/ECRForm.tsx

@@ -40,6 +40,7 @@ export default class ECRForm extends Component<PropsType, StateType> {
   handleSubmit = () => {
     let { awsRegion, awsAccessId, awsSecretKey, credentialsName } = this.state;
     let { currentProject } = this.context;
+
     api.createAWSIntegration('<token>', {
       aws_region: awsRegion,
       aws_access_key_id: awsAccessId,

+ 3 - 16
dashboard/src/main/home/navbar/Feedback.tsx

@@ -1,8 +1,8 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import axios from 'axios';
 import { Context } from '../../../shared/Context';
+import { handleSubmitFeedback } from '../../../shared/feedback';
 
 type PropsType = {
   currentView: string,
@@ -37,22 +37,9 @@ export default class Feedback extends Component<PropsType, StateType> {
   handleSubmitFeedback = () => {
     let { user } = this.context;
     let msg = '👤 ' + user.email + ' 📍 ' + this.props.currentView + ': ' + this.state.feedbackText;
-    axios.post('http://35.190.59.124/feedback', {
-      key: 'uzNP7MVYqDC7hs9Q8YP7ehvsBO4yRO02ZGYQ5rKJ2YngEqgYVBITRsvDww8CfV3q',
-      cid: '794372152769642507',
-      message: msg,
-    }, {
-      headers: {
-        Authorization: `Bearer <>`
-      }
-    })
-    .then(res => {
-      console.log('feedback sent');
+    handleSubmitFeedback(msg, () => {
+      this.setState({ feedbackSent: true, feedbackText: '' });
     })
-    .catch(err => {
-      console.log(err);
-    });
-    this.setState({ feedbackSent: true, feedbackText: '' });
   }
 
   renderFeedbackDropdown = () => {

+ 63 - 5
dashboard/src/main/home/new-project/NewProject.tsx

@@ -16,7 +16,7 @@ import SaveButton from '../../../components/SaveButton';
 const providers = ['aws', 'gcp', 'do',];
 
 type PropsType = {
-  setCurrentView: (x: string) => void,
+  setCurrentView: (x: string, data: any) => void,
 };
 
 type StateType = {
@@ -193,8 +193,67 @@ export default class NewProject extends Component<PropsType, StateType> {
     return false;
   }
 
+  provisionECR = (proj: ProjectType, callback: (proj: ProjectType, ecr: any) => void) => {
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+    }, { id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+
+      api.provisionECR('<token>', {
+        aws_integration_id: res.data.id,
+        ecr_name: `${proj.name}-registry`
+      }, {id: proj.id}, (err: any, ecr:any) => {
+        if (err) {
+          console.log(err)
+          return;
+        }
+
+        callback(proj, ecr);
+      })
+      
+    });
+  }
+
+  provisionEKS = (proj: ProjectType, ecr: any) => {
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+    }, { id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+        return;
+      }
+
+      api.provisionEKS('<token>', {
+        aws_integration_id: res.data.id,
+        eks_name: `${proj.name}-cluster`,
+      }, { id: proj.id}, (err: any, eks: any) => {
+        if (err) {
+          console.log(err)
+          return;
+        }
+
+        this.props.setCurrentView('provisioner', [
+          {infra_id: ecr?.data?.id, kind: ecr?.data?.kind},
+          {infra_id: eks?.data?.id, kind: eks?.data?.kind},
+        ]);
+      })
+    })
+  }
+
   createProject = () => {
     this.setState({ status: 'loading' });
+
     api.createProject('<token>', {
       name: this.state.projectName
     }, {}, (err: any, res: any) => {
@@ -210,12 +269,11 @@ export default class NewProject extends Component<PropsType, StateType> {
             if (res.data.length > 0) {
               let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
               this.context.setCurrentProject(proj);
-
-              // Handle provisioning logic
+              
               if (this.state.selectedProvider === 'aws') {
-                this.props.setCurrentView('provisioner');
+                this.provisionECR(proj, this.provisionEKS)
               } else {
-                this.props.setCurrentView('dashboard');
+                this.props.setCurrentView('dashboard', null);
               }
             } 
           }

+ 91 - 4
dashboard/src/main/home/new-project/Provisioner.tsx

@@ -7,23 +7,95 @@ import { integrationList } from '../../../shared/common';
 import loading from '../../../assets/loading.gif';
 
 import Helper from '../../../components/values-form/Helper';
+import { eventNames } from 'process';
 
 type PropsType = {
+  viewData: any,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
   logs: string[],
+  websockets: any[],
+  maxStep : Record<string, number>,
+  currentStep: Record<string, number>,
 };
 
-const loadMax = 40;
-
 export default class Provisioner extends Component<PropsType, StateType> {
   state = {
     logs: [] as string[],
+    websockets : [] as any[],
+    maxStep: {} as Record<string, any>,
+    currentStep: {} as Record<string, number>,
+  }
+
+  scrollToBottom = () => {
+    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
   }
 
   componentDidMount() {
-    this.setState({ logs: ['test-1', 'test-2'] });
+    let { currentProject } = this.context;
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+
+    let websockets = this.props.viewData.forEach((infra: any) => {
+      let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
+      
+      ws.onopen = () => {
+        console.log('connected to websocket')
+      }
+
+      ws.onmessage = (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data)
+        let data = event.map((msg: any) => { return `${infra.kind}: ${msg["Values"]["data"]}` })
+        let err = null
+
+        // check for error
+        event.forEach((e: any) => {
+          err = e["Values"]["kind"] == "error" ? e["Values"]["data"] : null
+        })
+
+        if (err) {
+          this.setState({ logs: [err] })
+        }
+        
+        if (!this.state.maxStep[infra.kind]) {
+          this.setState({
+            maxStep: {
+              ...this.state.maxStep,
+              [infra.kind] : event[event.length]["Values"]["created_resources"]
+            }
+          })
+        }
+
+        this.setState({ 
+          logs: [...this.state.logs, ...data], 
+          currentStep: {
+            ...this.state.currentStep,
+            [infra.kind] : event[event.length]["Values"]["created_resources"]
+          },
+        }, () => {
+          this.scrollToBottom()
+        })
+      }
+
+      ws.onerror = (err: ErrorEvent) => {
+        console.log(err)
+      }
+
+      ws.onclose = () => {
+        console.log('closing provisioner websocket')
+      }
+
+      return ws
+    });
+
+    this.setState({ websockets, logs: [] });
+  }
+
+  componentWillUnmount() {
+    this.state.websockets?.forEach((ws) => {
+      ws.close()
+    })
   }
 
   scrollRef = React.createRef<HTMLDivElement>();
@@ -35,6 +107,21 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
   
   render() {
+    let maxStep = 0;
+    let currentStep = 0;
+
+    for (let key in this.state.maxStep) {
+      maxStep += this.state.maxStep[key]
+    }
+
+    for (let key in this.state.currentStep) {
+      currentStep += this.state.currentStep[key]
+    }
+
+    if (currentStep === maxStep) {
+      this.props.setCurrentView('dashboard');
+    }
+
     return (
       <StyledProvisioner>
         <TitleSection>
@@ -46,7 +133,7 @@ export default class Provisioner extends Component<PropsType, StateType> {
         </Helper>
 
         <LoadingBar>
-          <Loaded progress={((7 / loadMax) * 100).toString() + '%'} />
+          <Loaded progress={((currentStep / maxStep) * 100).toString() + '%'} />
         </LoadingBar>
 
         <LogStream ref={this.scrollRef}>

+ 3 - 3
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -4,7 +4,7 @@ import drawerBg from '../../../assets/drawer-bg.png';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
-import { Cluster } from '../../../shared/types';
+import { ClusterType } from '../../../shared/types';
 
 import Drawer from './Drawer';
 
@@ -19,7 +19,7 @@ type PropsType = {
 type StateType = {
   showDrawer: boolean,
   initializedDrawer: boolean,
-  clusters: Cluster[],
+  clusters: ClusterType[],
 
   // Track last project id for refreshing clusters on project change
   prevProjectId: number
@@ -31,7 +31,7 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   state = {
     showDrawer: false,
     initializedDrawer: false,
-    clusters: [] as Cluster[],
+    clusters: [] as ClusterType[],
     prevProjectId: this.context.currentProject.id
   };
 

+ 3 - 3
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -3,12 +3,12 @@ import styled from 'styled-components';
 import close from '../../../assets/close.png';
 
 import { Context } from '../../../shared/Context';
-import { Cluster } from '../../../shared/types';
+import { ClusterType } from '../../../shared/types';
 
 type PropsType = {
   toggleDrawer: () => void,
   showDrawer: boolean,
-  clusters: Cluster[],
+  clusters: ClusterType[],
   setCurrentView: (x: string) => void
 };
 
@@ -22,7 +22,7 @@ export default class Drawer extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentCluster } = this.context;
 
     if (clusters.length > 0 && currentCluster) {
-      return clusters.map((cluster: Cluster, i: number) => {
+      return clusters.map((cluster: ClusterType, i: number) => {
         /*
         let active = this.context.activeProject &&
           this.context.activeProject.namespace == val.namespace; 

+ 28 - 7
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -4,11 +4,11 @@ import gradient from '../../../assets/gradient.jpg';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
-import { ProjectType } from '../../../shared/types';
+import { ProjectType, InfraType } from '../../../shared/types';
 
 type PropsType = {
   currentProject: ProjectType,
-  setCurrentView: (x: string) => void,
+  setCurrentView: (x: string, viewData?: any) => void,
   projects: ProjectType[],
 };
 
@@ -21,16 +21,37 @@ export default class ProjectSection extends Component<PropsType, StateType> {
     expanded: false,
   };
 
+  handleSelectProject = (project: ProjectType) => {
+    this.context.setCurrentProject(project);
+    
+    api.getInfra('<token>', {}, { project_id: project.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res.data) {
+        let anyProvisioning = false;
+        res.data.forEach((el: InfraType) => {
+          if (el.status === 'creating') {
+            anyProvisioning = true;
+            this.props.setCurrentView('provisioner', {
+              infra_id: el.id,
+              kind: el.kind,
+            });
+          }
+        });
+        if (!anyProvisioning) {
+          this.props.setCurrentView('dashboard');
+        }
+      }
+    });
+  }
+
   renderOptionList = () => {
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => {
-            this.context.setCurrentProject(project);
-            this.props.setCurrentView('dashboard');
-          }}
+          onClick={() => this.handleSelectProject(project)}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -55,7 +76,7 @@ export default class ProjectSection extends Component<PropsType, StateType> {
               onClick={() => this.props.setCurrentView('new-project')}
             >
               <ProjectIconAlt>+</ProjectIconAlt>
-              <ProjectLabel>Add a project</ProjectLabel>
+              <ProjectLabel>Create a Project</ProjectLabel>
             </Option>
           </Dropdown>
         </div>

+ 1 - 1
dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx

@@ -5,7 +5,7 @@ import { Context } from '../../../shared/Context';
 import ProjectSection from './ProjectSection';
 
 type PropsType = {
-  setCurrentView: (x: string) => void,
+  setCurrentView: (x: string, viewData?: any) => void,
 };
 
 type StateType = {

+ 1 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -13,7 +13,7 @@ import loading from '../../../assets/loading.gif';
 type PropsType = {
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string) => void,
+  setCurrentView: (x: string, viewData?: any) => void,
   currentView: string,
 };
 

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

@@ -5,7 +5,7 @@ import _ from 'lodash';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
 
-import { PorterTemplate, ChoiceType, Cluster, StorageType } from '../../../../shared/types';
+import { PorterTemplate, ChoiceType, ClusterType, StorageType } from '../../../../shared/types';
 import Selector from '../../../../components/Selector';
 import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import TabRegion from '../../../../components/TabRegion';
@@ -140,7 +140,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       if (err) {
         // console.log(err)
       } else if (res.data) {
-        let clusterOptions = res.data.map((x: Cluster) => { return { label: x.name, value: x.name } });
+        let clusterOptions = res.data.map((x: ClusterType) => { return { label: x.name, value: x.name } });
         if (res.data.length > 0) {
           this.setState({ clusterOptions });
         }

+ 11 - 3
dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import launch from '../../../../assets/launch.svg';
+import rocket from '../../../../assets/rocket.png';
 import Markdown from 'markdown-to-jsx';
 
 import { Context } from '../../../../shared/Context';
@@ -81,11 +81,12 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
             isDisabled={!currentCluster}
             onClick={!currentCluster ? null : this.props.launchTemplate}
           >
-            <img src={launch} />
+            <img src={rocket} />
             Launch Template
           </Button>
         </TitleSection>
         {this.renderTagSection()}
+        <LineBreak />
         <ContentSection>
           {this.renderMarkdown()}
         </ContentSection>
@@ -96,12 +97,19 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
 
 TemplateInfo.contextType = Context;
 
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 30px 0px 13px;
+`;
+
 const ContentSection = styled.div`
-  margin-top: 50px;
   font-size: 14px;
   line-height: 1.8em;
   padding-bottom: 100px;
   overflow: hidden;
+  user-select: text;
 `;
 
 const Tag = styled.div`

+ 3 - 3
dashboard/src/shared/Context.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from 'react';
 
-import { ProjectType, Cluster } from '../shared/types';
+import { ProjectType, ClusterType } from '../shared/types';
 
 type PropsType = {
 }
@@ -35,8 +35,8 @@ class ContextProvider extends Component {
     setCurrentError: (currentError: string) => {
       this.setState({ currentError });
     },
-    currentCluster: null as Cluster | null,
-    setCurrentCluster: (currentCluster: Cluster) => {
+    currentCluster: null as ClusterType | null,
+    setCurrentCluster: (currentCluster: ClusterType) => {
       this.setState({ currentCluster });
     },
     currentProject: null as ProjectType | null,

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

@@ -210,12 +210,27 @@ const getProjectRepos = baseApi<{}, { id: number }>('GET', pathParams => {
 
 const createAWSIntegration = baseApi<{
   aws_region: string,
+  aws_cluster_id?: string,
   aws_access_key_id: string,
   aws_secret_access_key: string,
 }, { id: number }>('POST', pathParams => {
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
+const provisionECR = baseApi<{
+  ecr_name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/provision/ecr`;
+});
+
+const provisionEKS = baseApi<{
+  eks_name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/provision/eks`;
+});
+
 const createECR = baseApi<{
   name: string,
   aws_integration_id: string,
@@ -254,8 +269,16 @@ const getGitRepos = baseApi<{
   return `/api/projects/${pathParams.project_id}/gitrepos`;
 });
 
+const getInfra = baseApi<{
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/infra`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  getInfra,
   linkGithubProject,
   getGitRepos,
   checkAuth,
@@ -292,6 +315,8 @@ export default {
   getProjectRegistries,
   getProjectRepos,
   createAWSIntegration,
+  provisionECR,
+  provisionEKS,
   createECR,
   getImageRepos,
   getImageTags,

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

@@ -0,0 +1,19 @@
+import axios from 'axios';
+
+export const handleSubmitFeedback = (msg: string, callback?: (err: any, res: any) => void) => {
+  axios.post(process.env.FEEDBACK_ENDPOINT, {
+    key: process.env.DISCORD_KEY,
+    cid: process.env.DISCORD_CID,
+    message: msg,
+  }, {
+    headers: {
+      Authorization: `Bearer <>`
+    }
+  })
+  .then(res => {
+    callback && callback(null, res);
+  })
+  .catch(err => {
+    callback && callback(err, null);
+  });
+}

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

@@ -1,4 +1,4 @@
-export interface Cluster {
+export interface ClusterType {
   id: number,
   name: string,
   server: string,
@@ -140,4 +140,11 @@ export interface ImageType {
   source: string,
   registryId: number,
   name: string,
+}
+
+export interface InfraType {
+  id: number,
+  project_d: number,
+  kind: string,
+  status: string,
 }

+ 5 - 2
go.mod

@@ -8,10 +8,13 @@ require (
 	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/aws/aws-sdk-go v1.35.4
+	github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20201113001948-d77edb6d2e47
 	github.com/containerd/containerd v1.4.1 // indirect
 	github.com/coreos/rkt v1.30.0
+	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
 	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/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0
@@ -46,7 +49,7 @@ require (
 	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 // indirect
+	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1

+ 10 - 0
go.sum

@@ -128,7 +128,12 @@ github.com/aws/aws-sdk-go v1.30.0 h1:7NDwnnQrI1Ivk0bXLzMmuX5ozzOwteHOsAs4druW7gI
 github.com/aws/aws-sdk-go v1.30.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
 github.com/aws/aws-sdk-go v1.31.6 h1:nKjQbpXhdImctBh1e0iLg9iQW/X297LPPuY/9f92R2k=
 github.com/aws/aws-sdk-go v1.31.6/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
+github.com/aws/aws-sdk-go v1.35.4 h1:GG0sdhmzQSe4/UcF9iuQP9i+58bPRyU4OpujyzMlVjo=
+github.com/aws/aws-sdk-go v1.35.4/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
+github.com/awslabs/amazon-ecr-credential-helper v0.4.0 h1:LYTmunbYJ8piWElip5hW2NpkEW5JfCbeB9hVHn5LIrc=
+github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20201113001948-d77edb6d2e47 h1:iBW2usmd8V2GsiAWIGGR1YCmdFm/iseBZyhTyx/ro6Q=
+github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20201113001948-d77edb6d2e47/go.mod h1:Z4a0MOGfjhxYBJ5E6pcbKUNVJ0bDyhiz68+N76ZtKhE=
 github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -417,6 +422,7 @@ github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18h
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1-0.20190508161146-9fa652df1129/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
 github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
 github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
 github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
@@ -632,6 +638,9 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5i
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc=
 github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
 github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
 github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
 github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd h1:nIzoSW6OhhppWLm4yqBwZsKJlAayUu5FGozhrF3ETSM=
@@ -1175,6 +1184,7 @@ golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190812172437-4e8604ab3aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

+ 4 - 4
internal/adapter/redis.go

@@ -11,10 +11,10 @@ import (
 // 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,
+		Addr:     fmt.Sprintf("%s:%s", conf.Host, conf.Port),
+		Username: conf.Username,
+		Password: conf.Password,
+		DB:       conf.DB,
 	})
 
 	_, err := client.Ping(context.Background()).Result()

+ 8 - 2
internal/config/redis.go

@@ -2,6 +2,12 @@ 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=6379"`
+	// if redis should be used
+	Enabled bool `env:"REDIS_ENABLED,default=true"`
+
+	Host     string `env:"REDIS_HOST,default=redis"`
+	Port     string `env:"REDIS_PORT,default=6379"`
+	Username string `env:"REDIS_USER"`
+	Password string `env:"REDIS_PASS"`
+	DB       int    `env:"REDIS_DB,default=0"`
 }

+ 18 - 0
internal/forms/infra.go

@@ -21,3 +21,21 @@ func (ce *CreateECRInfra) ToAWSInfra() (*models.AWSInfra, error) {
 		AWSIntegrationID: ce.AWSIntegrationID,
 	}, nil
 }
+
+// CreateEKSInfra represents the accepted values for creating an
+// EKS infra via the provisioning container
+type CreateEKSInfra struct {
+	EKSName          string `json:"eks_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 *CreateEKSInfra) ToAWSInfra() (*models.AWSInfra, error) {
+	return &models.AWSInfra{
+		Kind:             models.AWSInfraEKS,
+		ProjectID:        ce.ProjectID,
+		Status:           models.StatusCreating,
+		AWSIntegrationID: ce.AWSIntegrationID,
+	}, nil
+}

+ 1 - 0
internal/forms/registry.go

@@ -10,6 +10,7 @@ import (
 type CreateRegistry struct {
 	Name             string `json:"name" form:"required"`
 	ProjectID        uint   `json:"project_id" form:"required"`
+	URL              string `json:"url"`
 	GCPIntegrationID uint   `json:"gcp_integration_id"`
 	AWSIntegrationID uint   `json:"aws_integration_id"`
 }

+ 31 - 2
internal/kubernetes/agent.go

@@ -11,6 +11,8 @@ import (
 	"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/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
 
 	"github.com/gorilla/websocket"
@@ -236,10 +238,12 @@ func (a *Agent) ProvisionECR(
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	ecrName string,
+	awsInfra *models.AWSInfra,
 ) (*batchv1.Job, error) {
+	id := awsInfra.GetID()
 	prov := &provisioner.Conf{
-		ID:   fmt.Sprintf("%s-%d", ecrName, projectID),
-		Name: fmt.Sprintf("prov-%s-%d", ecrName, projectID),
+		ID:   id,
+		Name: fmt.Sprintf("prov-%s", id),
 		Kind: provisioner.ECR,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -254,6 +258,31 @@ func (a *Agent) ProvisionECR(
 	return a.provision(prov)
 }
 
+// ProvisionEKS spawns a new provisioning pod that creates an EKS instance
+func (a *Agent) ProvisionEKS(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	eksName string,
+	awsInfra *models.AWSInfra,
+) (*batchv1.Job, error) {
+	id := awsInfra.GetID()
+	prov := &provisioner.Conf{
+		ID:   id,
+		Name: fmt.Sprintf("prov-%s", id),
+		Kind: provisioner.EKS,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		EKS: &eks.Conf{
+			ClusterName: eksName,
+		},
+	}
+
+	return a.provision(prov)
+}
+
 // ProvisionTest spawns a new provisioning pod that tests provisioning
 func (a *Agent) ProvisionTest(
 	projectID uint,

+ 87 - 7
internal/kubernetes/provisioner/global_stream.go

@@ -2,7 +2,10 @@ package provisioner
 
 import (
 	"context"
+	"encoding/base64"
+	"encoding/json"
 	"fmt"
+	"regexp"
 
 	"github.com/porter-dev/porter/internal/repository"
 
@@ -45,6 +48,8 @@ func InitGlobalStream(client *redis.Client) error {
 		GlobalStreamName,
 	).Result()
 
+	fmt.Println(xInfoGroups, err)
+
 	if err != nil {
 		return err
 	}
@@ -52,6 +57,7 @@ func InitGlobalStream(client *redis.Client) error {
 	for _, group := range xInfoGroups {
 		// if the group exists, return with no error
 		if group.Name == GlobalStreamGroupName {
+			fmt.Println("group already exists")
 			return nil
 		}
 	}
@@ -61,9 +67,11 @@ func InitGlobalStream(client *redis.Client) error {
 		context.Background(),
 		GlobalStreamName,
 		GlobalStreamGroupName,
-		">",
+		"$",
 	).Result()
 
+	fmt.Println("xgroup created", err)
+
 	return err
 }
 
@@ -72,13 +80,15 @@ type ResourceCRUDHandler interface {
 	OnCreate(id uint) error
 }
 
-// GlobalStreamListener performs an XREADGROUP operation on a given stream
-// and sends a GlobalStreamMessage to the msgChan
+// GlobalStreamListener performs an XREADGROUP operation on a given stream and
+// updates models in the database as necessary
 func GlobalStreamListener(
 	client *redis.Client,
-	infraRepo repository.AWSInfraRepository,
+	repo repository.Repository,
 	errorChan chan error,
 ) {
+	fmt.Println("starting global stream listener")
+
 	for {
 		xstreams, err := client.XReadGroup(
 			context.Background(),
@@ -90,6 +100,8 @@ func GlobalStreamListener(
 			},
 		).Result()
 
+		fmt.Println(xstreams, err)
+
 		if err != nil {
 			errorChan <- err
 			return
@@ -98,10 +110,10 @@ func GlobalStreamListener(
 		// 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"]))
+			kind, projID, infraID, err := models.ParseWorkspaceID(fmt.Sprintf("%v", msg.Values["id"]))
 
 			if fmt.Sprintf("%v", msg.Values["status"]) == "created" {
-				infra, err := infraRepo.ReadAWSInfra(infraID)
+				infra, err := repo.AWSInfra.ReadAWSInfra(infraID)
 
 				if err != nil {
 					continue
@@ -109,7 +121,75 @@ func GlobalStreamListener(
 
 				infra.Status = models.StatusCreated
 
-				infra, err = infraRepo.UpdateAWSInfra(infra)
+				infra, err = repo.AWSInfra.UpdateAWSInfra(infra)
+
+				if err != nil {
+					continue
+				}
+
+				// create ECR/EKS
+				if kind == string(models.AWSInfraECR) {
+					reg := &models.Registry{
+						ProjectID:        projID,
+						AWSIntegrationID: infra.AWSIntegrationID,
+					}
+
+					// parse raw data into ECR type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), reg)
+					}
+
+					reg, err := repo.Registry.CreateRegistry(reg)
+
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.AWSInfraEKS) {
+					cluster := &models.Cluster{
+						AuthMechanism:    models.AWS,
+						ProjectID:        projID,
+						AWSIntegrationID: infra.AWSIntegrationID,
+					}
+
+					// parse raw data into ECR type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), cluster)
+					}
+
+					re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+					// if it matches the base64 regex, decode it
+					caData := string(cluster.CertificateAuthorityData)
+					if re.MatchString(caData) {
+						decoded, err := base64.StdEncoding.DecodeString(caData)
+
+						if err != nil {
+							continue
+						}
+
+						cluster.CertificateAuthorityData = []byte(decoded)
+					}
+
+					cluster, err := repo.Cluster.CreateCluster(cluster)
+
+					if err != nil {
+						continue
+					}
+				}
+			} else if fmt.Sprintf("%v", msg.Values["status"]) == "error" {
+				infra, err := repo.AWSInfra.ReadAWSInfra(infraID)
+
+				if err != nil {
+					continue
+				}
+
+				infra.Status = models.StatusError
+
+				infra, err = repo.AWSInfra.UpdateAWSInfra(infra)
 
 				if err != nil {
 					continue

+ 2 - 1
internal/kubernetes/provisioner/resource_stream.go

@@ -39,13 +39,14 @@ func ResourceStream(client *redis.Client, streamName string, conn *websocket.Con
 			).Result()
 
 			if err != nil {
+				fmt.Println("ERROR XREAD", err)
 				return
 			}
 
 			messages := xstream[0].Messages
 			lastID = messages[len(messages)-1].ID
 
-			if writeErr := conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprint(xstream))); writeErr != nil {
+			if writeErr := conn.WriteJSON(messages); writeErr != nil {
 				errorchan <- writeErr
 				return
 			}

+ 15 - 8
internal/models/infra.go

@@ -15,6 +15,7 @@ type InfraStatus string
 const (
 	StatusCreating InfraStatus = "creating"
 	StatusCreated  InfraStatus = "created"
+	StatusError    InfraStatus = "error"
 )
 
 // AWSInfraKind is the kind that aws infra can be
@@ -68,24 +69,30 @@ func (ai *AWSInfra) Externalize() *AWSInfraExternal {
 	}
 }
 
-// GetWorkspaceID returns the unique workspace id for this infra
-func (ai *AWSInfra) GetWorkspaceID() string {
+// GetID returns the unique id for this infra
+func (ai *AWSInfra) GetID() 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) {
+// ParseWorkspaceID returns the (kind, projectID, infraID)
+func ParseWorkspaceID(workspaceID string) (string, uint, uint, error) {
 	strArr := strings.Split(workspaceID, "-")
 
 	if len(strArr) != 3 {
-		return 0, fmt.Errorf("workspace id improperly formatted")
+		return "", 0, 0, fmt.Errorf("workspace id improperly formatted")
 	}
 
-	u, err := strconv.ParseUint(strArr[2], 10, 64)
+	projID, err := strconv.ParseUint(strArr[1], 10, 64)
 
 	if err != nil {
-		return 0, err
+		return "", 0, 0, err
 	}
 
-	return uint(u), nil
+	infraID, err := strconv.ParseUint(strArr[2], 10, 64)
+
+	if err != nil {
+		return "", 0, 0, err
+	}
+
+	return strArr[0], uint(projID), uint(infraID), nil
 }

+ 7 - 0
internal/models/registry.go

@@ -13,6 +13,9 @@ type Registry struct {
 	// Name of the registry
 	Name string `json:"name"`
 
+	// URL of the registry
+	URL string `json:"url"`
+
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
@@ -37,6 +40,9 @@ type RegistryExternal struct {
 	// Name of the registry
 	Name string `json:"name"`
 
+	// URL of the registry
+	URL string `json:"url"`
+
 	// The integration service for this registry
 	Service integrations.IntegrationService `json:"service"`
 }
@@ -55,6 +61,7 @@ func (r *Registry) Externalize() *RegistryExternal {
 		ID:        r.ID,
 		ProjectID: r.ProjectID,
 		Name:      r.Name,
+		URL:       r.URL,
 		Service:   serv,
 	}
 }

+ 15 - 16
server/api/api.go

@@ -6,7 +6,6 @@ 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"
@@ -33,11 +32,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
-	RedisClient *redis.Client
+	DB         *gorm.DB
+	Logger     *lr.Logger
+	Repository *repository.Repository
+	ServerConf config.ServerConf
+	RedisConf  *config.RedisConf
 
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
@@ -61,8 +60,8 @@ type App struct {
 	// agents exposed for testing
 	TestAgents *TestAgents
 
-	// redis conf for redis connection
-	RedisClient *redis.Client
+	// redis client for redis connection
+	RedisConf *config.RedisConf
 
 	// oauth-specific clients
 	GithubConf *oauth2.Config
@@ -86,14 +85,14 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:      conf.Logger,
-		Repo:        conf.Repository,
-		ServerConf:  conf.ServerConf,
-		RedisClient: conf.RedisClient,
-		TestAgents:  conf.TestAgents,
-		db:          conf.DB,
-		validator:   validator,
-		translator:  &translator,
+		Logger:     conf.Logger,
+		Repo:       conf.Repository,
+		ServerConf: conf.ServerConf,
+		RedisConf:  conf.RedisConf,
+		TestAgents: conf.TestAgents,
+		db:         conf.DB,
+		validator:  validator,
+		translator: &translator,
 	}
 
 	// if repository not specified, default to in-memory

+ 40 - 0
server/api/infra_handler.go

@@ -0,0 +1,40 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleListProjectInfra returns a list of infrasa for a project
+func (app *App) HandleListProjectInfra(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
+	}
+
+	infras, err := app.Repo.AWSInfra.ListAWSInfrasByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extInfras := make([]*models.AWSInfraExternal, 0)
+
+	for _, infra := range infras {
+		extInfras = append(extInfras, infra.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extInfras); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 91 - 1
server/api/provision_handler.go

@@ -11,6 +11,8 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+
+	"github.com/porter-dev/porter/internal/adapter"
 )
 
 // HandleProvisionTest will create a test resource by deploying a provisioner
@@ -101,6 +103,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		uint(projID),
 		awsInt,
 		form.ECRName,
+		infra,
 	)
 
 	if err != nil {
@@ -120,6 +123,86 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleProvisionAWSEKSInfra provisions a new aws EKS instance for a project
+func (app *App) HandleProvisionAWSEKSInfra(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.CreateEKSInfra{
+		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.ProvisionEKS(
+		uint(projID),
+		awsInt,
+		form.EKSName,
+		infra,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New aws eks 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
@@ -138,7 +221,14 @@ func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request
 		app.handleErrorUpgradeWebsocket(err, w)
 	}
 
-	err = provisioner.ResourceStream(app.RedisClient, streamName, conn)
+	client, err := adapter.NewRedisClient(app.RedisConf)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	err = provisioner.ResourceStream(client, streamName, conn)
 
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)

+ 104 - 0
server/api/registry_handler.go

@@ -4,12 +4,15 @@ import (
 	"encoding/json"
 	"net/http"
 	"strconv"
+	"time"
 
 	"github.com/porter-dev/porter/internal/registry"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/aws/aws-sdk-go/service/ecr"
 )
 
 // HandleCreateRegistry creates a new registry
@@ -45,6 +48,34 @@ func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// if the registry is ECR and URL is not set, get the registry url
+	if registry.URL == "" && registry.AWSIntegrationID != 0 {
+		awsInt, err := app.Repo.AWSIntegration.ReadAWSIntegration(registry.AWSIntegrationID)
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return
+		}
+
+		sess, err := awsInt.GetSession()
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return
+		}
+
+		ecrSvc := ecr.New(sess)
+
+		output, err := ecrSvc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return
+		}
+
+		registry.URL = *output.AuthorizationData[0].ProxyEndpoint
+	}
+
 	// handle write to the database
 	registry, err = app.Repo.Registry.CreateRegistry(registry)
 
@@ -95,6 +126,79 @@ func (app *App) HandleListProjectRegistries(w http.ResponseWriter, r *http.Reque
 	}
 }
 
+// temp -- token response
+type ECRTokenResponse struct {
+	Token     string     `json:"token"`
+	ExpiresAt *time.Time `json:"expires_at"`
+}
+
+// HandleGetProjectRegistryECRToken gets an ECR token for a registry
+func (app *App) HandleGetProjectRegistryECRToken(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
+	}
+
+	region := chi.URLParam(r, "region")
+
+	if region == "" {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// list registries and find one that matches the region
+	regs, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
+	var token string
+	var expiresAt *time.Time
+
+	for _, reg := range regs {
+		if reg.AWSIntegrationID != 0 {
+			awsInt, err := app.Repo.AWSIntegration.ReadAWSIntegration(reg.AWSIntegrationID)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			if awsInt.AWSRegion == region {
+				// get the aws integration and session
+				sess, err := awsInt.GetSession()
+
+				if err != nil {
+					app.handleErrorDataRead(err, w)
+					return
+				}
+
+				ecrSvc := ecr.New(sess)
+
+				output, err := ecrSvc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+				if err != nil {
+					app.handleErrorDataRead(err, w)
+					return
+				}
+
+				token = *output.AuthorizationData[0].AuthorizationToken
+				expiresAt = output.AuthorizationData[0].ExpiresAt
+			}
+		}
+	}
+
+	resp := &ECRTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleUpdateProjectRegistry updates a registry
 func (app *App) HandleUpdateProjectRegistry(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)

+ 33 - 4
server/router/router.go

@@ -177,11 +177,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
-		// /api/projects/{project_id}/provision routes
-
-		// TODO -- restrict this endpoint
+		// /api/projects/{project_id}/infra routes
 		r.Method(
 			"GET",
+			"/projects/{project_id}/infra",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectInfra, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/provision routes
+		r.Method(
+			"POST",
 			"/projects/{project_id}/provision/test",
 			auth.DoesUserHaveProjectAccess(
 				requestlog.NewHandler(a.HandleProvisionTest, l),
@@ -191,7 +200,7 @@ func New(a *api.App) *chi.Mux {
 		)
 
 		r.Method(
-			"GET",
+			"POST",
 			"/projects/{project_id}/provision/ecr",
 			auth.DoesUserHaveProjectAccess(
 				requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
@@ -200,6 +209,16 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/provision/eks",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleProvisionAWSEKSInfra, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/provision/{kind}/{infra_id}/logs",
@@ -401,6 +420,16 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/registries/ecr/{region}/token",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetProjectRegistryECRToken, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		r.Method(
 			"DELETE",
 			"/projects/{project_id}/registries/{registry_id}",