2
0
Эх сурвалжийг харах

Merge pull request #319 from porter-dev/master

Merge CI/CD + private docker registry support into staging
abelanger5 5 жил өмнө
parent
commit
db821171ae
36 өөрчлөгдсөн 1274 нэмэгдсэн , 298 устгасан
  1. 46 0
      .github/workflows/dev.yaml
  2. 46 0
      .github/workflows/production.yaml
  3. 76 0
      cli/cmd/api/registry.go
  4. 52 0
      cli/cmd/connect.go
  5. 76 0
      cli/cmd/connect/dockerhub.go
  6. 76 0
      cli/cmd/connect/registry.go
  7. 50 1
      cli/cmd/docker.go
  8. 1 1
      cli/cmd/version.go
  9. 1 1
      cmd/docker-credential-porter/main.go
  10. 132 32
      cmd/migrate/keyrotate/rotate.go
  11. 2 0
      cmd/migrate/main.go
  12. 3 1
      dashboard/src/components/image-selector/ImageList.tsx
  13. 4 28
      dashboard/src/components/image-selector/ImageSelector.tsx
  14. 82 43
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  15. 63 41
      dashboard/src/components/repo-selector/ActionDetails.tsx
  16. 1 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  17. 21 1
      dashboard/src/components/repo-selector/RepoList.tsx
  18. 35 36
      dashboard/src/components/repo-selector/RepoSelector.tsx
  19. 12 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  20. 1 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  21. 36 0
      dashboard/src/main/home/launch/Launch.tsx
  22. 29 50
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  23. 7 0
      internal/forms/git_action.go
  24. 14 12
      internal/forms/registry.go
  25. 3 0
      internal/forms/release.go
  26. 2 2
      internal/integrations/ci/actions/steps.go
  27. 2 0
      internal/models/cluster.go
  28. 13 11
      internal/models/integrations/integration.go
  29. 8 3
      internal/models/registry.go
  30. 256 0
      internal/registry/registry.go
  31. 19 0
      server/api/deploy_handler.go
  32. 28 18
      server/api/git_action_handler.go
  33. 3 9
      server/api/integration_handler.go
  34. 45 0
      server/api/registry_handler.go
  35. 19 0
      server/api/user_handler.go
  36. 10 0
      server/router/router.go

+ 46 - 0
.github/workflows/dev.yaml

@@ -0,0 +1,46 @@
+name: Deploy to production
+on:
+  push:
+    branches: 
+    - dev
+jobs:
+  deploy:
+    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: Install kubectl
+      run: |
+        sudo apt-get install kubectl
+    - name: Log in to gcloud CLI
+      run: gcloud auth configure-docker
+    - name: Checkout
+      uses: actions/checkout@v2.3.4
+    - name: Write Dashboard Environment Variables
+      run: |
+        cat >./dashboard/.env <<EOL
+        NODE_ENV=production
+        API_SERVER=dashboard.dev.getporter.dev
+        FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
+        DISCORD_KEY=${{secrets.DISCORD_KEY}}
+        DISCORD_CID=${{secrets.DISCORD_CID}}
+        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+        POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+        POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+        EOL
+    - name: Build
+      run: |
+        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./docker/Dockerfile
+    - name: Push
+      run: |
+        docker push gcr.io/porter-dev-273614/porter:dev
+    - name: Deploy to cluster
+      run: |
+        gcloud container clusters get-credentials \
+          dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          
+        kubectl rollout restart deployment/porter

+ 46 - 0
.github/workflows/production.yaml

@@ -0,0 +1,46 @@
+name: Deploy to production
+on:
+  push:
+    branches: 
+    - production
+jobs:
+  deploy:
+    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: Install kubectl
+      run: |
+        sudo apt-get install kubectl
+    - name: Log in to gcloud CLI
+      run: gcloud auth configure-docker
+    - name: Checkout
+      uses: actions/checkout@v2.3.4
+    - name: Write Dashboard Environment Variables
+      run: |
+        cat >./dashboard/.env <<EOL
+        NODE_ENV=production
+        API_SERVER=dashboard.getporter.dev
+        FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
+        DISCORD_KEY=${{secrets.DISCORD_KEY}}
+        DISCORD_CID=${{secrets.DISCORD_CID}}
+        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+        POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+        POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+        EOL
+    - name: Build
+      run: |
+        DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile
+    - name: Push
+      run: |
+        docker push gcr.io/porter-dev-273614/porter:latest
+    - name: Deploy to cluster
+      run: |
+        gcloud container clusters get-credentials \
+          production-2 --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          
+        kubectl rollout restart deployment/porter

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

@@ -59,6 +59,53 @@ func (c *Client) CreateECR(
 	return bodyResp, nil
 }
 
+// CreatePrivateRegistryRequest represents the accepted fields for creating
+// a private registry
+type CreatePrivateRegistryRequest struct {
+	Name               string `json:"name"`
+	URL                string `json:"url"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
+}
+
+// CreatePrivateRegistryResponse is the resulting registry after creation
+type CreatePrivateRegistryResponse models.RegistryExternal
+
+// CreatePrivateRegistry creates a private registry integration
+func (c *Client) CreatePrivateRegistry(
+	ctx context.Context,
+	projectID uint,
+	createPR *CreatePrivateRegistryRequest,
+) (*CreatePrivateRegistryResponse, error) {
+	data, err := json.Marshal(createPR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreatePrivateRegistryResponse{}
+
+	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
+}
+
 // CreateGCRRequest represents the accepted fields for creating
 // a GCR registry
 type CreateGCRRequest struct {
@@ -290,6 +337,35 @@ func (c *Client) GetGCRAuthorizationToken(
 	return bodyResp, nil
 }
 
+// GetDockerhubAuthorizationToken gets a Docker Hub authorization token
+func (c *Client) GetDockerhubAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+) (*GetTokenResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/dockerhub/token", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bodyResp := &GetTokenResponse{}
+	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
+}
+
 type GetDOCRTokenRequest struct {
 	ServerURL string `json:"server_url"`
 }

+ 52 - 0
cli/cmd/connect.go

@@ -43,6 +43,30 @@ var connectECRCmd = &cobra.Command{
 	},
 }
 
+var connectDockerhubCmd = &cobra.Command{
+	Use:   "dockerhub",
+	Short: "Adds a Docker Hub registry integration to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectDockerhub)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var connectRegistryCmd = &cobra.Command{
+	Use:   "registry",
+	Short: "Adds a custom image registry to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectRegistry)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectActionsCmd = &cobra.Command{
 	Use:   "actions",
 	Short: "Adds Github Actions to a project",
@@ -127,6 +151,8 @@ func init() {
 
 	connectCmd.AddCommand(connectActionsCmd)
 	connectCmd.AddCommand(connectECRCmd)
+	connectCmd.AddCommand(connectRegistryCmd)
+	connectCmd.AddCommand(connectDockerhubCmd)
 	connectCmd.AddCommand(connectGCRCmd)
 	connectCmd.AddCommand(connectDOCRCmd)
 	connectCmd.AddCommand(connectHRCmd)
@@ -193,6 +219,32 @@ func runConnectDOCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) er
 	return setRegistry(regID)
 }
 
+func runConnectDockerhub(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.Dockerhub(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
+func runConnectRegistry(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.Registry(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
 func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.Helm(
 		client,

+ 76 - 0
cli/cmd/connect/dockerhub.go

@@ -0,0 +1,76 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func Dockerhub(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for dockerhub name
+
+	repoName, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the Docker Hub image path, in the form of ${org_name}/${repo_name}. For example, porter1/porter.
+Image path: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	username, err := utils.PromptPlaintext(fmt.Sprintf(`Docker Hub username: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	password, err := utils.PromptPassword(`Provide the Docker Hub personal access token.
+Token:`)
+
+	if err != nil {
+		return 0, err
+	}
+
+	// create the basic auth integration
+	integration, err := client.CreateBasicAuthIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateBasicAuthIntegrationRequest{
+			Username: username,
+			Password: password,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created basic auth integration with id %d\n", integration.ID)
+
+	reg, err := client.CreatePrivateRegistry(
+		context.Background(),
+		projectID,
+		&api.CreatePrivateRegistryRequest{
+			URL:                fmt.Sprintf("index.docker.io/%s", repoName),
+			Name:               repoName,
+			BasicIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created private registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 76 - 0
cli/cmd/connect/registry.go

@@ -0,0 +1,76 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// Helm connects a Helm repository using HTTP basic authentication
+func Registry(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for helm repo name
+	repoURL, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the image registry URL (include the protocol). For example, https://my-custom-registry.getporter.dev.
+Image registry URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	username, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the username/password for authentication (press enter if no authenicaiton is required).
+Username: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	password, err := utils.PromptPasswordWithConfirmation()
+
+	if err != nil {
+		return 0, err
+	}
+
+	// create the basic auth integration
+	integration, err := client.CreateBasicAuthIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateBasicAuthIntegrationRequest{
+			Username: username,
+			Password: password,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created basic auth integration with id %d\n", integration.ID)
+
+	reg, err := client.CreatePrivateRegistry(
+		context.Background(),
+		projectID,
+		&api.CreatePrivateRegistryRequest{
+			URL:                repoURL,
+			Name:               repoURL,
+			BasicIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created private registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 50 - 1
cli/cmd/docker.go

@@ -2,7 +2,9 @@ package cmd
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"net/url"
 	"os"
@@ -16,6 +18,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/docker/cli/cli/config/configfile"
+	"github.com/docker/cli/cli/config/types"
 )
 
 var dockerCmd = &cobra.Command{
@@ -131,8 +134,54 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		return err
 	}
 
+	if config.CredentialHelpers == nil {
+		config.CredentialHelpers = make(map[string]string)
+	}
+
 	for _, regURL := range regToAdd {
-		config.CredentialHelpers[regURL] = "porter"
+		// if this is a dockerhub registry, see if an auth config has already been generated
+		// for index.docker.io
+		if strings.Contains(regURL, "index.docker.io") {
+			isAuthenticated := false
+
+			for key, _ := range config.AuthConfigs {
+				if key == "https://index.docker.io/v1/" {
+					isAuthenticated = true
+				}
+			}
+
+			if !isAuthenticated {
+				// get a dockerhub token from the Porter API
+				tokenResp, err := client.GetDockerhubAuthorizationToken(context.Background(), getProjectID())
+
+				if err != nil {
+					return err
+				}
+
+				decodedToken, err := base64.StdEncoding.DecodeString(tokenResp.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))
+				}
+
+				config.AuthConfigs["https://index.docker.io/v1/"] = types.AuthConfig{
+					Auth:     tokenResp.Token,
+					Username: parts[0],
+					Password: parts[1],
+				}
+
+				// since we're using token-based auth, unset the credstore
+				config.CredentialsStore = ""
+			}
+		} else {
+			config.CredentialHelpers[regURL] = "porter"
+		}
 	}
 
 	return config.Save()

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "v0.1.0-beta.3.4"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 1 - 1
cmd/docker-credential-porter/main.go

@@ -10,7 +10,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "v0.1.0-beta.3.4"
 
 func main() {
 	var versionFlag bool

+ 132 - 32
cmd/migrate/keyrotate/rotate.go

@@ -3,6 +3,8 @@ package keyrotate
 import (
 	"fmt"
 
+	"encoding/hex"
+
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	gorm "github.com/porter-dev/porter/internal/repository/gorm"
@@ -22,6 +24,10 @@ func Rotate(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 
 	fmt.Printf("beginning key rotation from %s to %s\n", string(oldKeyBytes), string(newKeyBytes))
 
+	for i, b := range oldKeyBytes {
+		fmt.Println(i, ":", string(b), string(newKeyBytes[i]))
+	}
+
 	err := rotateClusterModel(db, oldKey, newKey)
 
 	if err != nil {
@@ -127,7 +133,7 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		clusters := []*models.Cluster{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&clusters).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&clusters).Error; err != nil {
 			return err
 		}
 
@@ -138,6 +144,14 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			if err != nil {
 				return err
 			}
+			if err != nil {
+				fmt.Printf("error decrypting cluster %d\n", cluster.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				cluster.CertificateAuthorityData = []byte{}
+				cluster.TokenCache.Token = []byte{}
+			}
 		}
 
 		// encrypt with the new key and re-insert
@@ -145,6 +159,8 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptClusterData(cluster, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting cluster %d\n", cluster.ID)
+
 				return err
 			}
 
@@ -154,7 +170,7 @@ func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d clusters", count)
+	fmt.Printf("rotated %d clusters\n", count)
 
 	return nil
 }
@@ -174,7 +190,7 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		ccs := []*models.ClusterCandidate{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&ccs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&ccs).Error; err != nil {
 			return err
 		}
 
@@ -183,7 +199,12 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptClusterCandidateData(cc, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting cluster candidate %d\n", cc.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				cc.AWSClusterIDGuess = []byte{}
+				cc.Kubeconfig = []byte{}
 			}
 		}
 
@@ -192,6 +213,8 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptClusterCandidateData(cc, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting cluster candidate %d\n", cc.ID)
+
 				return err
 			}
 
@@ -201,7 +224,7 @@ func rotateClusterCandidateModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d cluster candidates", count)
+	fmt.Printf("rotated %d cluster candidates\n", count)
 
 	return nil
 }
@@ -221,7 +244,7 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		regs := []*models.Registry{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&regs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&regs).Error; err != nil {
 			return err
 		}
 
@@ -230,7 +253,12 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptRegistryData(reg, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting registry %d\n", reg.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				reg.TokenCache.Token = []byte{}
+
 			}
 		}
 
@@ -239,6 +267,8 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptRegistryData(reg, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting registry %d\n", reg.ID)
+
 				return err
 			}
 
@@ -248,7 +278,7 @@ func rotateRegistryModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d registries", count)
+	fmt.Printf("rotated %d registries\n", count)
 
 	return nil
 }
@@ -268,7 +298,7 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		hrs := []*models.HelmRepo{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&hrs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&hrs).Error; err != nil {
 			return err
 		}
 
@@ -277,7 +307,11 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptHelmRepoData(hr, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting helm repo %d\n", hr.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				hr.TokenCache.Token = []byte{}
 			}
 		}
 
@@ -286,6 +320,8 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptHelmRepoData(hr, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting helm repo %d\n", hr.ID)
+
 				return err
 			}
 
@@ -295,7 +331,7 @@ func rotateHelmRepoModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d helm repos", count)
+	fmt.Printf("rotated %d helm repos\n", count)
 
 	return nil
 }
@@ -315,7 +351,7 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		infras := []*models.Infra{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&infras).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&infras).Error; err != nil {
 			return err
 		}
 
@@ -324,7 +360,17 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptInfraData(infra, oldKey)
 
 			if err != nil {
-				return err
+				oldKeyBytes := make([]byte, 32)
+				newKeyBytes := make([]byte, 32)
+
+				copy(oldKeyBytes[:], oldKey[:])
+				copy(newKeyBytes[:], newKey[:])
+
+				fmt.Printf("error decrypting infra %d, %s, %s, %s\n", infra.ID, hex.EncodeToString(infra.LastApplied), string(oldKeyBytes), string(newKeyBytes))
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				infra.LastApplied = []byte{}
 			}
 		}
 
@@ -333,6 +379,8 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptInfraData(infra, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting infra %d\n", infra.ID)
+
 				return err
 			}
 
@@ -342,7 +390,7 @@ func rotateInfraModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d infras", count)
+	fmt.Printf("rotated %d infras\n", count)
 
 	return nil
 }
@@ -362,7 +410,7 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		kis := []*ints.KubeIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&kis).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&kis).Error; err != nil {
 			return err
 		}
 
@@ -371,7 +419,16 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptKubeIntegrationData(ki, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting kube integration %d\n", ki.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				ki.ClientCertificateData = []byte{}
+				ki.ClientKeyData = []byte{}
+				ki.Token = []byte{}
+				ki.Username = []byte{}
+				ki.Password = []byte{}
+				ki.Kubeconfig = []byte{}
 			}
 		}
 
@@ -380,6 +437,8 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptKubeIntegrationData(ki, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting kube integration %d\n", ki.ID)
+
 				return err
 			}
 
@@ -389,7 +448,7 @@ func rotateKubeIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d kube integrations", count)
+	fmt.Printf("rotated %d kube integrations\n", count)
 
 	return nil
 }
@@ -409,7 +468,7 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		basics := []*ints.BasicIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&basics).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&basics).Error; err != nil {
 			return err
 		}
 
@@ -418,7 +477,12 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptBasicIntegrationData(basic, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting basic integration %d\n", basic.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				basic.Username = []byte{}
+				basic.Password = []byte{}
 			}
 		}
 
@@ -427,6 +491,8 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptBasicIntegrationData(basic, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting basic integration %d\n", basic.ID)
+
 				return err
 			}
 
@@ -436,7 +502,7 @@ func rotateBasicIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d basic integrations", count)
+	fmt.Printf("rotated %d basic integrations\n", count)
 
 	return nil
 }
@@ -456,7 +522,7 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		oidcs := []*ints.OIDCIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&oidcs).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&oidcs).Error; err != nil {
 			return err
 		}
 
@@ -465,7 +531,16 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptOIDCIntegrationData(oidc, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting oidc integration %d\n", oidc.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				oidc.IssuerURL = []byte{}
+				oidc.ClientID = []byte{}
+				oidc.ClientSecret = []byte{}
+				oidc.CertificateAuthorityData = []byte{}
+				oidc.IDToken = []byte{}
+				oidc.RefreshToken = []byte{}
 			}
 		}
 
@@ -474,6 +549,8 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptOIDCIntegrationData(oidc, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting oidc integration %d\n", oidc.ID)
+
 				return err
 			}
 
@@ -483,7 +560,7 @@ func rotateOIDCIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d oidc integrations", count)
+	fmt.Printf("rotated %d oidc integrations\n", count)
 
 	return nil
 }
@@ -503,7 +580,7 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		oauths := []*ints.OAuthIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&oauths).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&oauths).Error; err != nil {
 			return err
 		}
 
@@ -512,7 +589,13 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptOAuthIntegrationData(oauth, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting oauth integration %d\n", oauth.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				oauth.ClientID = []byte{}
+				oauth.AccessToken = []byte{}
+				oauth.RefreshToken = []byte{}
 			}
 		}
 
@@ -521,6 +604,8 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptOAuthIntegrationData(oauth, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting oauth integration %d\n", oauth.ID)
+
 				return err
 			}
 
@@ -530,7 +615,7 @@ func rotateOAuthIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d oauth integrations", count)
+	fmt.Printf("rotated %d oauth integrations\n", count)
 
 	return nil
 }
@@ -550,7 +635,7 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		gcps := []*ints.GCPIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&gcps).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&gcps).Error; err != nil {
 			return err
 		}
 
@@ -559,7 +644,11 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptGCPIntegrationData(gcp, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error decrypting gcp integration %d\n", gcp.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				gcp.GCPKeyData = []byte{}
 			}
 		}
 
@@ -568,6 +657,8 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptGCPIntegrationData(gcp, newKey)
 
 			if err != nil {
+				fmt.Printf("error encrypting gcp integration %d\n", gcp.ID)
+
 				return err
 			}
 
@@ -577,7 +668,7 @@ func rotateGCPIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d gcp integrations", count)
+	fmt.Printf("rotated %d gcp integrations\n", count)
 
 	return nil
 }
@@ -597,7 +688,7 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 	for i := 0; i < (int(count)/stepSize)+1; i++ {
 		awss := []*ints.AWSIntegration{}
 
-		if err := db.Offset(i * stepSize).Limit(stepSize).Find(&awss).Error; err != nil {
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&awss).Error; err != nil {
 			return err
 		}
 
@@ -606,7 +697,14 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.DecryptAWSIntegrationData(aws, oldKey)
 
 			if err != nil {
-				return err
+				fmt.Printf("error encrypting aws integration %d\n", aws.ID)
+
+				// in these cases we'll wipe the data -- if it can't be decrypted, we can't
+				// recover it
+				aws.AWSAccessKeyID = []byte{}
+				aws.AWSClusterID = []byte{}
+				aws.AWSSecretAccessKey = []byte{}
+				aws.AWSSessionToken = []byte{}
 			}
 		}
 
@@ -615,6 +713,8 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 			err := repo.EncryptAWSIntegrationData(aws, newKey)
 
 			if err != nil {
+				fmt.Printf("error decrypting aws integration %d\n", aws.ID)
+
 				return err
 			}
 
@@ -624,7 +724,7 @@ func rotateAWSIntegrationModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
 		}
 	}
 
-	fmt.Printf("rotated %d aws integrations", count)
+	fmt.Printf("rotated %d aws integrations\n", count)
 
 	return nil
 }

+ 2 - 0
cmd/migrate/main.go

@@ -77,6 +77,8 @@ func main() {
 }
 
 type RotateConf struct {
+	// we add a dummy field to avoid empty struct issue with envdecode
+	DummyField       string `env:"ASDF,default=asdf"`
 	OldEncryptionKey string `env:"OLD_ENCRYPTION_KEY"`
 	NewEncryptionKey string `env:"NEW_ENCRYPTION_KEY"`
 }

+ 3 - 1
dashboard/src/components/image-selector/ImageList.tsx

@@ -14,6 +14,7 @@ type PropsType = {
   selectedTag: string | null;
   clickedImage: ImageType | null;
   registry?: any;
+  noTagSelection?: boolean;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
@@ -216,7 +217,8 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
   renderExpanded = () => {
     let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
-    if (!this.props.clickedImage) {
+
+    if (!this.props.clickedImage || this.props.noTagSelection) {
       return (
         <div>
           <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>

+ 4 - 28
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   selectedTag: string | null;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
+  noTagSelection?: boolean;
 };
 
 type StateType = {
@@ -173,32 +174,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     }
   };
 
-  renderExpanded = () => {
-    let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
-    if (!this.state.clickedImage) {
-      return (
-        <div>
-          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
-          {this.renderBackButton()}
-        </div>
-      );
-    } else {
-      return (
-        <div>
-          <ExpandedWrapper>
-            <TagList
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedTag={setSelectedTag}
-              registryId={this.state.clickedImage.registryId}
-            />
-          </ExpandedWrapper>
-          {this.renderBackButton()}
-        </div>
-      );
-    }
-  };
-
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
@@ -257,6 +232,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
+            noTagSelection={this.props.noTagSelection}
             setSelectedImageUrl={this.props.setSelectedImageUrl}
             setSelectedTag={this.props.setSelectedTag}
             setClickedImage={(x: ImageType) =>
@@ -319,7 +295,7 @@ const ImageItem = styled.div`
   font-size: 13px;
   border-bottom: 1px solid
     ${(props: { lastItem: boolean; isSelected: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+    props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
   user-select: none;
   align-items: center;
@@ -378,7 +354,7 @@ const Label = styled.div`
 
 const StyledImageSelector = styled.div`
   width: 100%;
-  margin-top: 22px;
+  margin-top: 10px;
   border: 1px solid #ffffff55;
   background: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
     props.isExpanded ? "#ffffff11" : ""};

+ 82 - 43
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -1,33 +1,34 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
+import React, { Component } from "react";
+import styled from "styled-components";
 
-import { ActionConfigType } from '../../shared/types';
-import { Context } from '../../shared/Context';
+import { ActionConfigType } from "shared/types";
+import { Context } from "shared/Context";
 
-import RepoList from './RepoList';
-import BranchList from './BranchList';
-import ContentsList from './ContentsList';
-import ActionDetails from './ActionDetails';
+import RepoList from "./RepoList";
+import BranchList from "./BranchList";
+import ContentsList from "./ContentsList";
+import ActionDetails from "./ActionDetails";
 
 type PropsType = {
-  actionConfig: ActionConfigType | null,
-  branch: string,
-  pathIsSet: boolean,
-  setActionConfig: (x: ActionConfigType) => void,
-  setBranch: (x: string) => void,
-  setPath: (x: boolean) => void,
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  pathIsSet: boolean;
+  setActionConfig: (x: ActionConfigType) => void;
+  setBranch: (x: string) => void;
+  setPath: (x: boolean) => void;
+  reset: () => void;
 };
 
 type StateType = {
-  loading: boolean,
-  error: boolean,
+  loading: boolean;
+  error: boolean;
 };
 
 export default class ActionConfEditor extends Component<PropsType, StateType> {
   state = {
     loading: true,
     error: false,
-  }
+  };
 
   renderExpanded = () => {
     let {
@@ -51,41 +52,55 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
       );
     } else if (!branch) {
       return (
-        <ExpandedWrapperAlt>
-          <BranchList
-            actionConfig={actionConfig}
-            setBranch={(branch: string) => setBranch(branch)}
-          />
-        </ExpandedWrapperAlt>
+        <>
+          <ExpandedWrapperAlt>
+            <BranchList
+              actionConfig={actionConfig}
+              setBranch={(branch: string) => setBranch(branch)}
+            />
+          </ExpandedWrapperAlt>
+          {this.renderResetButton()}
+        </>
       );
     } else if (!pathIsSet) {
       return (
+        <>
+          <ExpandedWrapperAlt>
+            <ContentsList
+              actionConfig={actionConfig}
+              branch={branch}
+              setActionConfig={setActionConfig}
+              setPath={() => setPath(true)}
+            />
+          </ExpandedWrapperAlt>
+          {this.renderResetButton()}
+        </>
+      );
+    }
+    return (
+      <>
         <ExpandedWrapperAlt>
-          <ContentsList
+          <ActionDetails
             actionConfig={actionConfig}
-            branch={branch}
             setActionConfig={setActionConfig}
-            setPath={() => setPath(true)}
           />
         </ExpandedWrapperAlt>
-      );
-    }
-    return (
-      <ExpandedWrapperAlt>
-        <ActionDetails
-          actionConfig={actionConfig}
-          setActionConfig={setActionConfig}
-        />
-      </ExpandedWrapperAlt>
-    )
-  }
+        {this.renderResetButton()}
+      </>
+    );
+  };
 
-  render() {
+  renderResetButton = () => {
     return (
-      <>
-        {this.renderExpanded()}
-      </>
+      <BackButton width="150px" onClick={this.props.reset}>
+        <i className="material-icons">keyboard_backspace</i>
+        Reset Selection
+      </BackButton>
     );
+  };
+
+  render() {
+    return <>{this.renderExpanded()}</>;
   }
 }
 
@@ -100,5 +115,29 @@ const ExpandedWrapper = styled.div`
   overflow-y: auto;
 `;
 
-const ExpandedWrapperAlt = styled(ExpandedWrapper)`
-`;
+const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 63 - 41
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -1,31 +1,32 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
+import ImageSelector from "components/image-selector/ImageSelector";
+import React, { Component } from "react";
+import styled from "styled-components";
 
-import { Context } from '../../shared/Context';
-import { ActionConfigType } from '../../shared/types';
-import InputRow from '../values-form/InputRow';
+import { Context } from "../../shared/Context";
+import { ActionConfigType } from "../../shared/types";
+import InputRow from "../values-form/InputRow";
 
 type PropsType = {
-  actionConfig: ActionConfigType | null,
-  setActionConfig: (x: ActionConfigType) => void,
+  actionConfig: ActionConfigType | null;
+  setActionConfig: (x: ActionConfigType) => void;
 };
 
 type StateType = {
-  dockerRepo: string,
-  error: boolean,
+  dockerRepo: string;
+  error: boolean;
 };
 
 export default class ActionDetails extends Component<PropsType, StateType> {
   state = {
-    dockerRepo: '',
+    dockerRepo: "",
     error: false,
-  }
+  };
 
   componentDidMount() {
     if (this.props.actionConfig.dockerfile_path) {
-      this.setPath('/Dockerfile');
+      this.setPath("/Dockerfile");
     } else {
-      this.setPath('Dockerfile');
+      this.setPath("Dockerfile");
     }
   }
 
@@ -34,58 +35,79 @@ export default class ActionDetails extends Component<PropsType, StateType> {
     let updatedConfig = actionConfig;
     updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.concat(x);
     setActionConfig(updatedConfig);
-  }
+  };
 
   setURL = (x: string) => {
     let { actionConfig, setActionConfig } = this.props;
     let updatedConfig = actionConfig;
     updatedConfig.image_repo_uri = x;
     setActionConfig(updatedConfig);
-  }
+  };
 
   renderConfirmation = () => {
-    let { actionConfig } = this.props;
+    var imageComponent
+
+    if (!this.props.actionConfig.image_repo_uri) {
+      imageComponent = <div>
+          <Label>Target Image URL</Label>
+          <ImageSelector
+            selectedTag="latest"
+            selectedImageUrl={this.props.actionConfig.image_repo_uri}
+            setSelectedImageUrl={this.setURL}
+            setSelectedTag={() => null}
+            forceExpanded={true}
+            noTagSelection={true}
+          />
+        </div>
+    } else {
+      imageComponent = <InputRow
+        disabled={true}
+        label="Target Image URL"
+        type="text"
+        width="100%"
+        value={this.props.actionConfig.image_repo_uri}
+        setValue={(x: string) => console.log(x)}
+      />
+    }
+
     return (
       <Holder>
         <InputRow
           disabled={true}
-          label='Git Repository'
-          type='text'
-          width='100%'
-          value={actionConfig.git_repo}
+          label="Git Repository"
+          type="text"
+          width="100%"
+          value={this.props.actionConfig.git_repo}
           setValue={(x: string) => console.log(x)}
         />
         <InputRow
           disabled={true}
-          label='Dockerfile Path'
-          type='text'
-          width='100%'
-          value={actionConfig.dockerfile_path}
+          label="Dockerfile Path"
+          type="text"
+          width="100%"
+          value={this.props.actionConfig.dockerfile_path}
           setValue={(x: string) => console.log(x)}
         />
-        <InputRow
-          label='Docker Image Repository'
-          placeholder='Image Repo URI (ex. my-repo/image)'
-          type='text'
-          width='100%'
-          value={actionConfig.image_repo_uri}
-          setValue={(x: string) => this.setURL(x)}
-        />
+        {imageComponent}
       </Holder>
-    )
-  }
+    );
+  };
 
   render() {
-    return (
-      <div>
-        {this.renderConfirmation()}
-      </div>
-    );
+    return <div>{this.renderConfirmation()}</div>;
   }
 }
 
+const Label = styled.div`
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
 ActionDetails.contextType = Context;
 
 const Holder = styled.div`
-  padding: 0px 12px;
-`;
+  padding: 0px 12px 24px 12px;
+`;

+ 1 - 1
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -160,7 +160,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
     return (
       <FileItem lastItem={false}>
         <img src={info} />
-        Select subfolder (Optional)
+        Select path to Dockerfile
       </FileItem>
     );
   };

+ 21 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -107,7 +107,18 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
     } else if (error || !repos) {
       return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
     } else if (repos.length == 0) {
-      return <LoadingWrapper>No connected repos found.</LoadingWrapper>;
+      return (
+        <LoadingWrapper>
+          No connected Github repos found. You can
+          <A
+            padRight={true}
+            href={`/api/oauth/projects/${this.context.currentProject.id}/github?redirected=true`}
+          >
+            log in with GitHub
+          </A>{" "}
+          .
+        </LoadingWrapper>
+      );
     }
 
     return repos.map((repo: RepoType, i: number) => {
@@ -205,3 +216,12 @@ const ExpandedWrapperAlt = styled(ExpandedWrapper)`
   max-height: 275px;
   overflow-y: auto;
 `;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+  padding-right: ${(props: { padRight?: boolean }) =>
+    props.padRight ? "5px" : ""};
+`;

+ 35 - 36
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -1,44 +1,41 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import github from 'assets/github.png';
-import info from 'assets/info.svg';
-import { RepoType, ChartType, ActionConfigType } from '../../shared/types';
-import { Context } from '../../shared/Context';
+import React, { Component } from "react";
+import styled from "styled-components";
+import github from "assets/github.png";
+import info from "assets/info.svg";
+import { RepoType, ChartType, ActionConfigType } from "shared/types";
+import { Context } from "shared/Context";
 
-import ButtonTray from './ButtonTray';
-import ActionConfEditor from './ActionConfEditor';
+import ButtonTray from "./ButtonTray";
+import ActionConfEditor from "./ActionConfEditor";
 
 type PropsType = {
-  chart: ChartType | null,
-  forceExpanded?: boolean,
-  actionConfig: ActionConfigType | null,
-  setActionConfig: (x: ActionConfigType) => void,
+  chart: ChartType | null;
+  forceExpanded?: boolean;
+  actionConfig: ActionConfigType | null;
+  setActionConfig: (x: ActionConfigType) => void;
+  resetActionConfig: () => void;
 };
 
 type StateType = {
-  isExpanded: boolean,
-  repos: RepoType[]
-  branch: string,
-  pathIsSet: boolean,
-  dockerfileSelected: boolean,
+  isExpanded: boolean;
+  repos: RepoType[];
+  branch: string;
+  pathIsSet: boolean;
+  dockerfileSelected: boolean;
 };
 
 export default class RepoSelector extends Component<PropsType, StateType> {
   state = {
     isExpanded: this.props.forceExpanded,
     repos: [] as RepoType[],
-    branch: '',
+    branch: "",
     pathIsSet: false,
     dockerfileSelected: false,
-  }
+  };
 
   renderExpanded = () => {
-    let {
-      actionConfig,
-      setActionConfig,
-      chart,
-    } = this.props;
-    
+    let { actionConfig, setActionConfig, chart } = this.props;
+
     return (
       <div>
         <ActionConfEditor
@@ -48,6 +45,14 @@ export default class RepoSelector extends Component<PropsType, StateType> {
           setActionConfig={setActionConfig}
           setBranch={(branch: string) => this.setState({ branch })}
           setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+          reset={() => {
+            this.setState({
+              branch: "",
+              pathIsSet: false,
+              dockerfileSelected: false,
+            });
+            this.props.resetActionConfig();
+          }}
         />
         <ButtonTray
           chartName={chart.name}
@@ -66,13 +71,16 @@ export default class RepoSelector extends Component<PropsType, StateType> {
   renderSelected = () => {
     let { actionConfig } = this.props;
     if (actionConfig.git_repo) {
-      let subdir = actionConfig.dockerfile_path === '' ? '' : '/' + actionConfig.dockerfile_path;
+      let subdir =
+        actionConfig.dockerfile_path === ""
+          ? ""
+          : "/" + actionConfig.dockerfile_path;
       return (
         <RepoLabel>
           <img src={github} />
           {actionConfig.git_repo + subdir}
           <SelectedBranch>
-            {!this.state.branch ? '(Select Branch)' : this.state.branch}
+            {!this.state.branch ? "(Select Branch)" : this.state.branch}
           </SelectedBranch>
         </RepoLabel>
       );
@@ -120,15 +128,6 @@ const SelectedBranch = styled.div`
   margin-left: 10px;
 `;
 
-const ExpandedWrapper = styled.div`
-  margin-top: 10px;
-  width: 100%;
-  border-radius: 3px;
-  border: 1px solid #ffffff44;
-  max-height: 275px;
-  overflow-y: auto;
-`;
-
 const RepoLabel = styled.div`
   display: flex;
   align-items: center;

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

@@ -36,14 +36,17 @@ type StateType = {
   action: ActionConfigType;
 };
 
+// TODO: put in shared, duped from LaunchTemplate.tsx
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_repo_id: 0,
+  dockerfile_path: "",
+};
+
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    actionConfig: {
-      git_repo: "",
-      image_repo_uri: "",
-      git_repo_id: 0,
-      dockerfile_path: "",
-    } as ActionConfigType,
+    actionConfig: defaultActionConfig,
     sourceType: "",
     selectedImageUrl: "",
     selectedTag: "",
@@ -219,6 +222,9 @@ export default class SettingsSection extends Component<PropsType, StateType> {
           setActionConfig={(actionConfig: ActionConfigType) =>
             this.setState({ actionConfig })
           }
+          resetActionConfig={() =>
+            this.setState({ actionConfig: defaultActionConfig })
+          }
         />
       </>
     );

+ 1 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -79,7 +79,7 @@ class Dashboard extends Component<PropsType, StateType> {
             <>
               <Banner>
                 <i className="material-icons">error_outline</i>
-                This project currently has no clusters conncted.
+                This project currently has no clusters connected.
               </Banner>
               <ProvisionerSettings infras={this.state.infras} />
             </>

+ 36 - 0
dashboard/src/main/home/launch/Launch.tsx

@@ -10,6 +10,7 @@ import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
 import Loading from "components/Loading";
 
 import hardcodedNames from "./hardcodedNameDict";
+import { Link } from "react-router-dom";
 
 const tabOptions = [
   { label: "Launch service", value: "docker" },
@@ -110,6 +111,25 @@ export default class Templates extends Component<PropsType, StateType> {
   };
 
   renderDefaultTemplate = () => {
+    if (!this.context.currentCluster) {
+      return (
+        <>
+          <Banner>
+            <i className="material-icons">error_outline</i>
+            <Link to="dashboard">Provision</Link> &nbsp;or&nbsp;
+            <Link
+              to="#"
+              onClick={() =>
+                this.context.setCurrentModal("ClusterInstructionsModal")
+              }
+            >
+              connect
+            </Link>
+            &nbsp;to a cluster
+          </Banner>
+        </>
+      );
+    }
     if (this.state.currentTemplate) {
       return (
         <ExpandedTemplate
@@ -186,6 +206,22 @@ const Placeholder = styled.div`
   }
 `;
 
+const Banner = styled.div`
+  height: 40px;
+  width: 100%;
+  margin: 30px 0 30px;
+  font-size: 13px;
+  display: flex;
+  border-radius: 5px;
+  padding-left: 15px;
+  align-items: center;
+  background: #ffffff11;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding-top: 300px;
 `;

+ 29 - 50
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -50,6 +50,13 @@ type StateType = {
   pathIsSet: boolean;
 };
 
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_repo_id: 0,
+  dockerfile_path: "",
+};
+
 export default class LaunchTemplate extends Component<PropsType, StateType> {
   state = {
     currentView: "repo",
@@ -66,12 +73,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     currentTab: null as string | null,
     tabContents: [] as any,
     namespaceOptions: [] as { label: string; value: string }[],
-    actionConfig: {
-      git_repo: "",
-      image_repo_uri: "",
-      git_repo_id: 0,
-      dockerfile_path: "",
-    } as ActionConfigType,
+    actionConfig: { ...defaultActionConfig },
     branch: "",
     pathIsSet: false,
   };
@@ -214,7 +216,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
           version: "latest",
         }
       )
-      .then((res) => {
+      .then((_) => {
         if (this.state.sourceType === "repo") {
           this.createGHAction(name, this.state.selectedNamespace);
         }
@@ -404,8 +406,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelectorContent = () => {
-    let { currentProject } = this.context;
-
     if (this.props.form?.hasSource) {
       if (this.state.sourceType === "registry") {
         return (
@@ -433,14 +433,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         return (
           <>
             <Subtitle>
-              Select a repo to connect to. You can
-              <A
-                padRight={true}
-                href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
-              >
-                log in with GitHub
-              </A>{" "}
-              .<Required>*</Required>
+              Select a repo to connect to, then a Dockerfile to build from.
+              <Required>*</Required>
             </Subtitle>
             <ActionConfEditor
               actionConfig={this.state.actionConfig}
@@ -455,6 +449,13 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
               }
               setBranch={(branch: string) => this.setState({ branch })}
               setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+              reset={() => {
+                this.setState({
+                  actionConfig: { ...defaultActionConfig },
+                  branch: "",
+                  pathIsSet: false,
+                });
+              }}
             />
             <br />
           </>
@@ -577,13 +578,6 @@ const Link = styled.a`
   margin-left: 5px;
 `;
 
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 35px 0px 35px;
-`;
-
 const Wrapper = styled.div`
   width: 100%;
   position: relative;
@@ -693,25 +687,6 @@ const Flex = styled.div`
   }
 `;
 
-const Title = styled.div`
-  font-size: 24px;
-  font-weight: 600;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 11px;
-  border-radius: 2px;
-  color: #ffffff;
-`;
-
-const TitleSection = styled.div`
-  display: flex;
-  margin-left: -42px;
-  height: 40px;
-  flex-direction: row;
-  justify-content: space-between;
-  width: calc(100% + 42px);
-  align-items: center;
-`;
-
 const StyledLaunchTemplate = styled.div`
   width: 100%;
   padding-bottom: 150px;
@@ -726,13 +701,17 @@ const Highlight = styled.div`
     props.padRight ? "5px" : ""};
 `;
 
-const A = styled.a`
-  color: #8590ff;
-  text-decoration: underline;
-  margin-left: 5px;
-  cursor: pointer;
-  padding-right: ${(props: { padRight?: boolean }) =>
-    props.padRight ? "5px" : ""};
+const StyledSourceBox = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 10px 35px 25px;
+  position: relative;
+  border-radius: 5px;
+  font-size: 13px;
+  overflow: auto;
+  margin-bottom: 25px;
 `;
 
 const StyledSourceBox = styled.div`

+ 7 - 0
internal/forms/git_action.go

@@ -24,3 +24,10 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 		GitRepoID:      ca.GitRepoID,
 	}, nil
 }
+
+type CreateGitActionOptional struct {
+	GitRepo        string `json:"git_repo"`
+	ImageRepoURI   string `json:"image_repo_uri"`
+	DockerfilePath string `json:"dockerfile_path"`
+	GitRepoID      uint   `json:"git_repo_id"`
+}

+ 14 - 12
internal/forms/registry.go

@@ -9,23 +9,25 @@ import (
 // CreateRegistry represents the accepted values for creating a
 // registry
 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"`
-	DOIntegrationID  uint   `json:"do_integration_id"`
+	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"`
+	DOIntegrationID    uint   `json:"do_integration_id"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
 }
 
 // ToRegistry converts the form to a gorm registry model
 func (cr *CreateRegistry) ToRegistry(repo repository.Repository) (*models.Registry, error) {
 	registry := &models.Registry{
-		Name:             cr.Name,
-		ProjectID:        cr.ProjectID,
-		URL:              cr.URL,
-		GCPIntegrationID: cr.GCPIntegrationID,
-		AWSIntegrationID: cr.AWSIntegrationID,
-		DOIntegrationID:  cr.DOIntegrationID,
+		Name:               cr.Name,
+		ProjectID:          cr.ProjectID,
+		URL:                cr.URL,
+		GCPIntegrationID:   cr.GCPIntegrationID,
+		AWSIntegrationID:   cr.AWSIntegrationID,
+		DOIntegrationID:    cr.DOIntegrationID,
+		BasicIntegrationID: cr.BasicIntegrationID,
 	}
 
 	if registry.URL == "" && registry.AWSIntegrationID != 0 {

+ 3 - 0
internal/forms/release.go

@@ -129,4 +129,7 @@ type ChartTemplateForm struct {
 type InstallChartTemplateForm struct {
 	*ReleaseForm
 	*ChartTemplateForm
+
+	// optional git action config
+	GithubActionConfig *CreateGitActionOptional `json:"github_action,omitempty"`
 }

+ 2 - 2
internal/integrations/ci/actions/steps.go

@@ -29,7 +29,7 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 
 const configure string = `
 porter auth login --token ${{secrets.%s}}
-porter docker configure
+sudo porter docker configure
 `
 
 func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
@@ -54,7 +54,7 @@ func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep
 }
 
 const deployPorter string = `
-curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)&repository=%s'
+curl -X POST "https://dashboard.getporter.dev/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)&repository=%s"
 `
 
 func deployPorterWebhookStep(webhookTokenSecretName, repoURL string) GithubActionYAMLStep {

+ 2 - 0
internal/models/cluster.go

@@ -96,6 +96,8 @@ func (c *Cluster) Externalize() *ClusterExternal {
 		serv = integrations.EKS
 	} else if c.GCPIntegrationID != 0 {
 		serv = integrations.GKE
+	} else if c.DOIntegrationID != 0 {
+		serv = integrations.DOKS
 	}
 
 	return &ClusterExternal{

+ 13 - 11
internal/models/integrations/integration.go

@@ -5,17 +5,19 @@ type IntegrationService string
 
 // The list of supported third-party services
 const (
-	GKE      IntegrationService = "gke"
-	GCS      IntegrationService = "gcs"
-	S3       IntegrationService = "s3"
-	HelmRepo IntegrationService = "helm"
-	EKS      IntegrationService = "eks"
-	Kube     IntegrationService = "kube"
-	GCR      IntegrationService = "gcr"
-	ECR      IntegrationService = "ecr"
-	DOCR     IntegrationService = "docr"
-	Github   IntegrationService = "github"
-	Docker   IntegrationService = "docker"
+	GKE       IntegrationService = "gke"
+	DOKS      IntegrationService = "doks"
+	GCS       IntegrationService = "gcs"
+	S3        IntegrationService = "s3"
+	HelmRepo  IntegrationService = "helm"
+	EKS       IntegrationService = "eks"
+	Kube      IntegrationService = "kube"
+	GCR       IntegrationService = "gcr"
+	ECR       IntegrationService = "ecr"
+	DOCR      IntegrationService = "docr"
+	Github    IntegrationService = "github"
+	DockerHub IntegrationService = "dockerhub"
+	Docker    IntegrationService = "docker"
 )
 
 // PorterIntegration is a supported integration service, specifying an auth

+ 8 - 3
internal/models/registry.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"gorm.io/gorm"
 )
@@ -26,9 +28,10 @@ type Registry struct {
 	// All fields below this line are encrypted before storage
 	// ------------------------------------------------------------------
 
-	GCPIntegrationID uint
-	AWSIntegrationID uint
-	DOIntegrationID  uint
+	GCPIntegrationID   uint
+	AWSIntegrationID   uint
+	DOIntegrationID    uint
+	BasicIntegrationID uint
 
 	// A token cache that can be used by an auth mechanism (integration), if desired
 	TokenCache integrations.RegTokenCache
@@ -64,6 +67,8 @@ func (r *Registry) Externalize() *RegistryExternal {
 		serv = integrations.GCR
 	} else if r.DOIntegrationID != 0 {
 		serv = integrations.DOCR
+	} else if strings.Contains(r.URL, "index.docker.io") {
+		serv = integrations.DockerHub
 	}
 
 	return &RegistryExternal{

+ 256 - 0
internal/registry/registry.go

@@ -71,6 +71,10 @@ func (r *Registry) ListRepositories(
 		return r.listDOCRRepositories(repo, doAuth)
 	}
 
+	if r.BasicIntegrationID != 0 {
+		return r.listPrivateRegistryRepositories(repo)
+	}
+
 	return nil, fmt.Errorf("error listing repositories")
 }
 
@@ -252,6 +256,102 @@ func (r *Registry) listDOCRRepositories(
 	return res, nil
 }
 
+func (r *Registry) listPrivateRegistryRepositories(
+	repo repository.Repository,
+) ([]*Repository, error) {
+	// handle dockerhub different, as it doesn't implement the docker registry http api
+	if strings.Contains(r.URL, "docker.io") {
+		// in this case, we just return the single dockerhub repository that's linked
+		res := make([]*Repository, 0)
+
+		res = append(res, &Repository{
+			Name: strings.Split(r.URL, "docker.io/")[1],
+			URI:  r.URL,
+		})
+
+		return res, nil
+	}
+
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
+	client := &http.Client{}
+
+	// get the host and scheme to make the request
+	parsedURL, err := url.Parse(r.URL)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s://%s/v2/_catalog", parsedURL.Scheme, parsedURL.Host),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if the status code is 404, fallback to the Docker Hub implementation
+	if resp.StatusCode == 404 {
+		req, err := http.NewRequest(
+			"GET",
+			fmt.Sprintf("%s/", r.URL),
+			nil,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		fmt.Println("AUTH IS", string(basic.Username), string(basic.Password))
+
+		req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+		resp, err = client.Do(req)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	gcrResp := gcrRepositoryResp{}
+
+	fmt.Println("STATUS IS", resp.Status)
+
+	if err := json.NewDecoder(resp.Body).Decode(&gcrResp); err != nil {
+		return nil, fmt.Errorf("Could not read private registry repositories: %v", err)
+	}
+
+	res := make([]*Repository, 0)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, repo := range gcrResp.Repositories {
+		res = append(res, &Repository{
+			Name: repo,
+			URI:  parsedURL.Host + "/" + repo,
+		})
+	}
+
+	return res, nil
+}
+
 func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
 	return &ints.TokenCache{
 		Token:  r.TokenCache.Token,
@@ -296,6 +396,10 @@ func (r *Registry) ListImages(
 		return r.listDOCRImages(repoName, repo, doAuth)
 	}
 
+	if r.BasicIntegrationID != 0 {
+		return r.listPrivateRegistryImages(repoName, repo)
+	}
+
 	return nil, fmt.Errorf("error listing images")
 }
 
@@ -444,6 +548,118 @@ func (r *Registry) listDOCRImages(
 	return res, nil
 }
 
+func (r *Registry) listPrivateRegistryImages(repoName string, repo repository.Repository) ([]*Image, error) {
+	// handle dockerhub different, as it doesn't implement the docker registry http api
+	if strings.Contains(r.URL, "docker.io") {
+		return r.listDockerHubImages(repoName, repo)
+	}
+
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
+	client := &http.Client{}
+
+	// get the host and scheme to make the request
+	parsedURL, err := url.Parse(r.URL)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s://%s/v2/%s/tags/list", parsedURL.Scheme, parsedURL.Host, repoName),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	gcrResp := gcrImageResp{}
+
+	if err := json.NewDecoder(resp.Body).Decode(&gcrResp); err != nil {
+		return nil, fmt.Errorf("Could not read private registry repositories: %v", err)
+	}
+
+	res := make([]*Image, 0)
+
+	for _, tag := range gcrResp.Tags {
+		res = append(res, &Image{
+			RepositoryName: repoName,
+			Tag:            tag,
+		})
+	}
+
+	return res, nil
+}
+
+type dockerHubImageResult struct {
+	Name string `json:"name"`
+}
+
+type dockerHubImageResp struct {
+	Results []dockerHubImageResult `json:"results"`
+}
+
+func (r *Registry) listDockerHubImages(repoName string, repo repository.Repository) ([]*Image, error) {
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := &http.Client{}
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("https://hub.docker.com/v2/repositories/%s/tags", strings.Split(r.URL, "docker.io/")[1]),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(string(basic.Username), string(basic.Password))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	imageResp := dockerHubImageResp{}
+
+	if err := json.NewDecoder(resp.Body).Decode(&imageResp); err != nil {
+		return nil, fmt.Errorf("Could not read private registry repositories: %v", err)
+	}
+
+	res := make([]*Image, 0)
+
+	for _, result := range imageResp.Results {
+		res = append(res, &Image{
+			RepositoryName: repoName,
+			Tag:            result.Name,
+		})
+	}
+
+	return res, nil
+}
+
 // GetDockerConfigJSON returns a dockerconfigjson file contents with "auths"
 // populated.
 func (r *Registry) GetDockerConfigJSON(
@@ -466,6 +682,10 @@ func (r *Registry) GetDockerConfigJSON(
 		conf, err = r.getDOCRDockerConfigFile(repo, doAuth)
 	}
 
+	if r.BasicIntegrationID != 0 {
+		conf, err = r.getPrivateRegistryDockerConfigFile(repo)
+	}
+
 	if err != nil {
 		return nil, err
 	}
@@ -596,6 +816,42 @@ func (r *Registry) getDOCRDockerConfigFile(
 	}, nil
 }
 
+func (r *Registry) getPrivateRegistryDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		r.BasicIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	authConfigKey := parsedURL.Host
+
+	if strings.Contains(r.URL, "index.docker.io") {
+		authConfigKey = "https://index.docker.io/v1/"
+	}
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			authConfigKey: types.AuthConfig{
+				Username: string(basic.Username),
+				Password: string(basic.Password),
+				Auth:     generateAuthToken(string(basic.Username), string(basic.Password)),
+			},
+		},
+	}, nil
+}
+
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
 }

+ 19 - 0
server/api/deploy_handler.go

@@ -138,6 +138,25 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		}, w)
 	}
 
+	// if github action config is linked, call the github action config handler
+	if form.GithubActionConfig != nil {
+		gaForm := &forms.CreateGitAction{
+			ReleaseID:      release.ID,
+			GitRepo:        form.GithubActionConfig.GitRepo,
+			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
+			DockerfilePath: form.GithubActionConfig.DockerfilePath,
+			GitRepoID:      form.GithubActionConfig.GitRepoID,
+		}
+
+		// validate the form
+		if err := app.validator.Struct(form); err != nil {
+			app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+			return
+		}
+
+		app.createGitActionFromForm(projID, release, name, gaForm, w, r)
+	}
+
 	w.WriteHeader(http.StatusOK)
 }
 

+ 28 - 18
server/api/git_action_handler.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/models"
 )
 
 // HandleCreateGitAction creates a new Github action in a repository for a given
@@ -56,10 +57,28 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	gaExt := app.createGitActionFromForm(projID, release, name, form, w, r)
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(gaExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+func (app *App) createGitActionFromForm(
+	projID uint64,
+	release *models.Release,
+	name string,
+	form *forms.CreateGitAction,
+	w http.ResponseWriter,
+	r *http.Request,
+) *models.GitActionConfigExternal {
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
-		return
+		return nil
 	}
 
 	// convert the form to a git action config
@@ -67,7 +86,7 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
+		return nil
 	}
 
 	// read the git repo
@@ -75,21 +94,21 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
+		return nil
 	}
 
 	repoSplit := strings.Split(gitAction.GitRepo, "/")
 
 	if len(repoSplit) != 2 {
 		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
-		return
+		return nil
 	}
 
 	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+		return nil
 	}
 
 	userID, _ := session.Values["user_id"].(uint)
@@ -102,9 +121,8 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 	})
 
 	if err != nil {
-		fmt.Println("ERROR GENERATING TOKEN", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+		return nil
 	}
 
 	// create the commit in the git repo
@@ -125,9 +143,8 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 	_, err = gaRunner.Setup()
 
 	if err != nil {
-		fmt.Println("ERROR RUNNING SETUP", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+		return nil
 	}
 
 	// handle write to the database
@@ -135,17 +152,10 @@ func (app *App) HandleCreateGitAction(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
-		return
+		return nil
 	}
 
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 
-	w.WriteHeader(http.StatusCreated)
-
-	gaExt := ga.Externalize()
-
-	if err := json.NewEncoder(w).Encode(gaExt); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
+	return ga.Externalize()
 }

+ 3 - 9
server/api/integration_handler.go

@@ -66,15 +66,13 @@ func (app *App) HandleListRepoIntegrations(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateGCPIntegration creates a new GCP integration in the DB
 func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	userID, _ := session.Values["user_id"].(uint)
-
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -129,15 +127,13 @@ func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateAWSIntegration creates a new AWS integration in the DB
 func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	userID, _ := session.Values["user_id"].(uint)
-
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -192,15 +188,13 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
 func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	userID, _ := session.Values["user_id"].(uint)
-
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {

+ 45 - 0
server/api/registry_handler.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"net/http"
 	"strconv"
@@ -174,6 +175,50 @@ func (app *App) HandleGetProjectRegistryECRToken(w http.ResponseWriter, r *http.
 	}
 }
 
+// HandleGetProjectRegistryDockerhubToken gets a Dockerhub token for a registry
+func (app *App) HandleGetProjectRegistryDockerhubToken(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
+	}
+
+	// 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.BasicIntegrationID != 0 && strings.Contains(reg.URL, "index.docker.io") {
+			basic, err := app.Repo.BasicIntegration.ReadBasicIntegration(reg.BasicIntegrationID)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			token = base64.StdEncoding.EncodeToString([]byte(string(basic.Username) + ":" + string(basic.Password)))
+
+			// we'll just set an arbitrary 30-day expiry time (this is not enforced)
+			timeExpires := time.Now().Add(30 * 24 * 3600 * time.Second)
+			expiresAt = &timeExpires
+		}
+	}
+
+	resp := &RegTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 type GCRTokenRequestBody struct {
 	ServerURL string `json:"server_url"`
 }

+ 19 - 0
server/api/user_handler.go

@@ -480,3 +480,22 @@ func (app *App) sendUser(w http.ResponseWriter, userID uint, email, redirect str
 	}
 	return nil
 }
+
+func (app *App) getUserIDFromRequest(r *http.Request) (uint, error) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		return 0, err
+	}
+
+	// first, check for token
+	tok := app.getTokenFromRequest(r)
+
+	if tok != nil {
+		return tok.IBy, nil
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	return userID, nil
+}

+ 10 - 0
server/router/router.go

@@ -753,6 +753,16 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/registries/dockerhub/token",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetProjectRegistryDockerhubToken, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/registries/docr/token",