瀏覽代碼

Merge branch 'master' into nico/por-391-tag-grouping-for-applications-and-jobs-backup-2

Porter Support 4 年之前
父節點
當前提交
8d7bb92183
共有 66 個文件被更改,包括 16385 次插入534 次删除
  1. 1 1
      .github/workflows/dev.yaml
  2. 8 4
      .github/workflows/prerelease.yaml
  3. 6 5
      .github/workflows/production.yaml
  4. 5 4
      .github/workflows/staging.yaml
  5. 3 4
      api/client/environment.go
  6. 19 0
      api/client/registry.go
  7. 119 0
      api/server/handlers/infra/forms.go
  8. 66 0
      api/server/handlers/project_integration/create_azure.go
  9. 37 0
      api/server/handlers/registry/create.go
  10. 54 0
      api/server/handlers/registry/get_token.go
  11. 57 12
      api/server/handlers/webhook/github_incoming.go
  12. 28 0
      api/server/router/project.go
  13. 28 0
      api/server/router/project_integration.go
  14. 32 0
      api/types/project_integration.go
  15. 9 0
      api/types/registry.go
  16. 1 1
      build/Dockerfile.osx
  17. 1 1
      build/Dockerfile.win
  18. 1 0
      cli/cmd/apply.go
  19. 10 25
      cli/cmd/delete.go
  20. 30 0
      cli/cmd/docker/auth.go
  21. 1 1
      cli/cmd/pack/pack.go
  22. 44 0
      cli/cmd/preview/os_env_driver.go
  23. 74 0
      cli/cmd/preview/push_image_driver.go
  24. 49 2
      cli/cmd/preview/update_config_driver.go
  25. 14098 1
      dashboard/package-lock.json
  26. 1 0
      dashboard/package.json
  27. 279 0
      dashboard/src/assets/devicons-name-list.ts
  28. 47 31
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  29. 10 9
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  30. 123 176
      dashboard/src/hosted.index.html
  31. 6 0
      dashboard/src/index.tsx
  32. 1 6
      dashboard/src/main/Main.tsx
  33. 9 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  34. 16 6
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  35. 24 13
      dashboard/src/main/home/onboarding/Routes.tsx
  36. 45 59
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  37. 16 4
      dashboard/src/main/home/sidebar/Sidebar.tsx
  38. 65 0
      dashboard/src/shared/error_handling/logger.ts
  39. 24 1
      dashboard/src/shared/error_handling/sentry/setup.ts
  40. 13 2
      dashboard/webpack.config.js
  41. 1 1
      docker/Dockerfile
  42. 1 1
      docker/cli.Dockerfile
  43. 1 1
      docker/dev.Dockerfile
  44. 1 1
      ee/docker/ee.Dockerfile
  45. 1 1
      ee/docker/provisioner.Dockerfile
  46. 10 0
      ee/integrations/vault/types.go
  47. 38 0
      ee/integrations/vault/vault.go
  48. 42 32
      go.mod
  49. 193 90
      go.sum
  50. 2 17
      internal/integrations/ci/actions/preview.go
  51. 5 8
      internal/integrations/ci/actions/steps.go
  52. 0 2
      internal/kubernetes/prometheus/metrics.go
  53. 56 0
      internal/models/integrations/azure.go
  54. 1 0
      internal/models/project.go
  55. 4 0
      internal/models/registry.go
  56. 262 0
      internal/registry/registry.go
  57. 12 0
      internal/repository/credentials/credentials.go
  58. 230 0
      internal/repository/gorm/auth.go
  59. 1 0
      internal/repository/gorm/migrate.go
  60. 6 0
      internal/repository/gorm/repository.go
  61. 9 0
      internal/repository/integrations.go
  62. 1 0
      internal/repository/repository.go
  63. 40 0
      internal/repository/test/auth.go
  64. 6 0
      internal/repository/test/repository.go
  65. 1 1
      services/cli_install_script_container/Dockerfile
  66. 1 1
      services/porter_cli_container/dev.Dockerfile

+ 1 - 1
.github/workflows/dev.yaml

@@ -39,7 +39,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=development
+          SENTRY_ENV=frontend-development
           EOL
       - name: Build
         run: |

+ 8 - 4
.github/workflows/prerelease.yaml

@@ -52,7 +52,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v2
         with:
-          go-version: 1.17
+          go-version: 1.18
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL
@@ -119,7 +119,7 @@ jobs:
       - name: Set up Go
         uses: actions/setup-go@v2
         with:
-          go-version: 1.17
+          go-version: 1.18
       - name: Write Dashboard Environment Variables
         run: |
           cat >./dashboard/.env <<EOL
@@ -251,8 +251,8 @@ jobs:
     name: Zip binaries, create release and upload assets
     runs-on: ubuntu-latest
     needs:
-    - notarize
-    - build-linux
+      - notarize
+      - build-linux
     steps:
       - name: Get tag name
         id: tag_name
@@ -517,3 +517,7 @@ jobs:
         run: gh workflow run porter_test_docker_production.yml --repo porter-dev/new-release-tests
         env:
           GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}
+      - name: Run test_porter_cli.yml workflow
+        run: gh workflow run test_porter_cli.yml --repo porter-dev/new-release-tests
+        env:
+          GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}

+ 6 - 5
.github/workflows/production.yaml

@@ -22,7 +22,7 @@ jobs:
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -37,7 +37,8 @@ jobs:
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           IS_HOSTED=true
-          COHERE_KEY=${{secrets.COHERE_KEY}}
+          ENABLE_COHERE=true
+          COHERE_API_KEY=${{secrets.COHERE_KEY}}
           INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
           INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
           SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
@@ -46,7 +47,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=production
+          SENTRY_ENV=frontend-production
           EOL
       - name: Build
         run: |
@@ -77,7 +78,7 @@ jobs:
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -92,4 +93,4 @@ jobs:
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
             
-          kubectl rollout restart deployment/provisioner
+          kubectl rollout restart deployment/provisioner

+ 5 - 4
.github/workflows/staging.yaml

@@ -22,7 +22,7 @@ jobs:
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout
@@ -36,7 +36,8 @@ jobs:
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           IS_HOSTED=true
-          COHERE_KEY=${{secrets.COHERE_KEY}}
+          ENABLE_COHERE=true
+          COHERE_API_KEY=${{secrets.COHERE_KEY}}
           INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
           INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
           SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
@@ -45,7 +46,7 @@ jobs:
           ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
-          SENTRY_ENV=staging
+          SENTRY_ENV=frontend-staging
           EOL
       - name: Build
         run: |
@@ -76,7 +77,7 @@ jobs:
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
       - name: Checkout

+ 3 - 4
api/client/environment.go

@@ -109,13 +109,12 @@ func (c *Client) FinalizeDeployment(
 
 func (c *Client) DeleteDeployment(
 	ctx context.Context,
-	projID, clusterID uint,
-	envID, gitRepoOwner, gitRepoName, prNumber string,
+	projID, clusterID, deploymentID uint,
 ) error {
 	return c.deleteRequest(
 		fmt.Sprintf(
-			"/projects/%d/clusters/%d/deployments/%s/%s/%s/%s",
-			projID, clusterID, envID, gitRepoOwner, gitRepoName, prNumber,
+			"/projects/%d/clusters/%d/deployments/%d",
+			projID, clusterID, deploymentID,
 		),
 		nil, nil,
 	)

+ 19 - 0
api/client/registry.go

@@ -123,6 +123,25 @@ func (c *Client) GetGCRAuthorizationToken(
 	return resp, err
 }
 
+// GetACRAuthorizationToken gets a ACR authorization token
+func (c *Client) GetACRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/acr/token",
+			projectID,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
 // GetDockerhubAuthorizationToken gets a Docker Hub authorization token
 func (c *Client) GetDockerhubAuthorizationToken(
 	ctx context.Context,

+ 119 - 0
api/server/handlers/infra/forms.go

@@ -361,12 +361,16 @@ tabs:
         options:
         - label: t2.medium
           value: t2.medium
+        - label: t2.large
+          value: t2.large
         - label: t2.xlarge
           value: t2.xlarge
         - label: t2.2xlarge
           value: t2.2xlarge
         - label: t3.medium
           value: t3.medium
+        - label: t3.large
+          value: t3.large
         - label: t3.xlarge
           value: t3.xlarge
         - label: t3.2xlarge
@@ -381,12 +385,117 @@ tabs:
       required: true
       placeholder: my-cluster
       variable: cluster_name
+    - type: number-input
+      label: Minimum number of EC2 instances to create in the application autoscaling group.
+      variable: min_instances
+      placeholder: "ex: 1"
+      settings:
+        default: 1
     - type: number-input
       label: Maximum number of EC2 instances to create in the application autoscaling group.
       variable: max_instances
       placeholder: "ex: 10"
       settings:
         default: 10
+- name: additional_nodegroup
+  label: Additional Node Groups
+  sections:
+  - name: is_additional_enabled
+    contents:
+    - type: heading
+      label: Additional Node Groups
+    - type: checkbox
+      variable: additional_nodegroup_enabled
+      label: Enable an additional node group for this cluster.
+      settings:
+        default: false
+  - name: additional_settings
+    show_if: additional_nodegroup_enabled
+    contents:
+    - type: string-input
+      label: Label for this node group.
+      variable: additional_nodegroup_label
+      placeholder: "ex: porter.run/workload-kind=job"
+      settings:
+        default: porter.run/workload-kind=database
+    - type: string-input
+      label: Taint for this node group.
+      variable: additional_nodegroup_taint
+      placeholder: "ex: porter.run/workload-kind=job:NoSchedule"
+      settings:
+        default: porter.run/workload-kind=database:NoSchedule
+    - type: checkbox
+      variable: additional_stateful_nodegroup_enabled
+      label: Stateful Workload
+      settings:
+        default: false
+    - type: select
+      label: ⚙️ AWS System Machine Type
+      variable: additional_nodegroup_machine_type
+      settings:
+        default: t2.medium
+        options:
+        - label: t2.medium
+          value: t2.medium
+        - label: t2.large
+          value: t2.large
+        - label: t2.xlarge
+          value: t2.xlarge
+        - label: t2.2xlarge
+          value: t2.2xlarge
+        - label: t3.medium
+          value: t3.medium
+        - label: t3.large
+          value: t3.large
+        - label: t3.xlarge
+          value: t3.xlarge
+        - label: t3.2xlarge
+          value: t3.2xlarge
+    - type: number-input
+      label: Minimum number of EC2 instances to create in the application autoscaling group.
+      variable: additional_nodegroup_min_instances
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: number-input
+      label: Maximum number of EC2 instances to create in the application autoscaling group.
+      variable: additional_nodegroup_max_instances
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+- name: advanced
+  label: Advanced
+  sections:
+  - name: system_machine_type
+    contents:
+    - type: heading
+      label: System Machine Type Settings
+    - type: select
+      label: ⚙️ AWS System Machine Type
+      variable: system_machine_type
+      settings:
+        default: t2.medium
+        options:
+        - label: t2.medium
+          value: t2.medium
+        - label: t2.large
+          value: t2.large
+        - label: t2.xlarge
+          value: t2.xlarge
+        - label: t2.2xlarge
+          value: t2.2xlarge
+        - label: t3.medium
+          value: t3.medium
+        - label: t3.large
+          value: t3.large
+        - label: t3.xlarge
+          value: t3.xlarge
+        - label: t3.2xlarge
+          value: t3.2xlarge
+  - name: spot_instance_should_enable
+    contents:
+    - type: heading
+      label: Spot Instance Settings
     - type: checkbox
       variable: spot_instances_enabled
       label: Enable spot instances for this cluster.
@@ -399,6 +508,16 @@ tabs:
       label: Assign a bid price for the spot instance (optional).
       variable: spot_price
       placeholder: "ex: 0.05"
+  - name: net_settings
+    contents:
+    - type: heading
+      label: Networking Settings
+    - type: string-input
+      label: "Add a different CIDR range prefix (first two octets: for example 10.99 will create a VPC with CIDR range 10.99.0.0/16)."
+      variable: cluster_vpc_cidr_octets
+      placeholder: "ex: 10.99"
+      settings:
+        default: "10.99"
 `
 
 const gcrForm = `name: GCR

+ 66 - 0
api/server/handlers/project_integration/create_azure.go

@@ -0,0 +1,66 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type CreateAzureHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateAzureHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateAzureHandler {
+	return &CreateAzureHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreateAzureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateAzureRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	az := CreateAzureIntegration(request, project.ID, user.ID)
+
+	az, err := p.Repo().AzureIntegration().CreateAzureIntegration(az)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateAzureResponse{
+		AzureIntegration: az.ToAzureIntegrationType(),
+	}
+
+	p.WriteResult(w, r, res)
+}
+
+func CreateAzureIntegration(request *types.CreateAzureRequest, projectID, userID uint) *ints.AzureIntegration {
+	resp := &ints.AzureIntegration{
+		UserID:                 userID,
+		ProjectID:              projectID,
+		AzureClientID:          request.AzureClientID,
+		AzureSubscriptionID:    request.AzureSubscriptionID,
+		AzureTenantID:          request.AzureTenantID,
+		ServicePrincipalSecret: []byte(request.ServicePrincipalKey),
+	}
+
+	return resp
+}

+ 37 - 0
api/server/handlers/registry/create.go

@@ -1,6 +1,7 @@
 package registry
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -57,6 +58,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		AWSIntegrationID:   request.AWSIntegrationID,
 		DOIntegrationID:    request.DOIntegrationID,
 		BasicIntegrationID: request.BasicIntegrationID,
+		AzureIntegrationID: request.AzureIntegrationID,
 	}
 
 	if regModel.URL == "" && regModel.AWSIntegrationID != 0 {
@@ -68,6 +70,41 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 
 		regModel.URL = url
+	} else if request.AzureIntegrationID != 0 {
+		// if azure integration id is non-zero check that resource group name and repo name are set
+		if request.ACRName == "" {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("acr_name must be set if azure_integration_id is not 0"),
+				http.StatusBadRequest,
+			))
+
+			return
+		} else if request.ACRResourceGroupName == "" {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("acr_resource_group_name must be set if azure_integration_id is not 0"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		// get the azure integration and overwrite the names
+		az, err := p.Repo().AzureIntegration().ReadAzureIntegration(proj.ID, request.AzureIntegrationID)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		az.ACRName = request.ACRName
+		az.ACRResourceGroupName = request.ACRResourceGroupName
+
+		az, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	}
 
 	// handle write to the database

+ 54 - 0
api/server/handlers/registry/get_token.go

@@ -293,3 +293,57 @@ func (c *RegistryGetDockerhubTokenHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 	c.WriteResult(w, r, resp)
 }
+
+type RegistryGetACRTokenHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRegistryGetACRTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RegistryGetACRTokenHandler {
+	return &RegistryGetACRTokenHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RegistryGetACRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// list registries and find one that matches the region
+	regs, err := c.Repo().Registry().ListRegistriesByProjectID(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var token string
+	var expiresAt *time.Time
+
+	for _, reg := range regs {
+		if reg.AzureIntegrationID != 0 && strings.Contains(reg.URL, "azurecr.io") {
+			_reg := registry.Registry(*reg)
+
+			username, pw, err := _reg.GetACRCredentials(c.Repo())
+
+			if err != nil {
+				continue
+			}
+
+			token = base64.StdEncoding.EncodeToString([]byte(string(username) + ":" + string(pw)))
+
+			// 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 := &types.GetRegistryTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 57 - 12
api/server/handlers/webhook/github_incoming.go

@@ -1,9 +1,11 @@
 package webhook
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
@@ -126,18 +128,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					return err
 				}
 			} else {
-				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
-					r.Context(), owner, repo, fmt.Sprintf("porter_%s_delete_env.yml", env.Name),
-					github.CreateWorkflowDispatchEventRequest{
-						Ref: event.PullRequest.GetHead().GetRef(),
-						Inputs: map[string]interface{}{
-							"environment_id": strconv.FormatUint(uint64(depl.EnvironmentID), 10),
-							"repo_owner":     owner,
-							"repo_name":      repo,
-							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
-						},
-					},
-				)
+				err = c.deleteDeployment(r, depl, env, client)
 
 				if err != nil {
 					return err
@@ -149,6 +140,60 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	return nil
 }
 
+func (c *GithubIncomingWebhookHandler) deleteDeployment(
+	r *http.Request,
+	depl *models.Deployment,
+	env *models.Environment,
+	client *github.Client,
+) error {
+	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
+
+	if err != nil {
+		return err
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		return err
+	}
+
+	// make sure we don't delete default or kube-system by checking for prefix, for now
+	if strings.Contains(depl.Namespace, "pr-") {
+		err = agent.DeleteNamespace(depl.Namespace)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// Create new deployment status to indicate deployment is ready
+	state := "inactive"
+
+	deploymentStatusRequest := github.DeploymentStatusRequest{
+		State: &state,
+	}
+
+	client.Repositories.CreateDeploymentStatus(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		depl.GHDeploymentID,
+		&deploymentStatusRequest,
+	)
+
+	depl.Status = types.DeploymentStatusInactive
+
+	// update the deployment to mark it inactive
+	_, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

+ 28 - 0
api/server/router/project.go

@@ -607,6 +607,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	//  GET /api/projects/{project_id}/registries/acr/token -> registry.NewRegistryGetACRTokenHandler
+	getACRTokenEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/registries/acr/token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getACRTokenHandler := registry.NewRegistryGetACRTokenHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getACRTokenEndpoint,
+		Handler:  getACRTokenHandler,
+		Router:   r,
+	})
+
 	//  GET /api/projects/{project_id}/registries/dockerhub/token -> registry.NewRegistryGetDockerhubTokenHandler
 	getDockerhubTokenEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 28 - 0
api/server/router/project_integration.go

@@ -272,5 +272,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/integrations/azure -> project_integration.NewCreateAzureHandler
+	createAzureEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/azure",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createAzureHandler := project_integration.NewCreateAzureHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createAzureEndpoint,
+		Handler:  createAzureHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 32 - 0
api/types/project_integration.go

@@ -125,3 +125,35 @@ type CreateGCPRequest struct {
 type CreateGCPResponse struct {
 	*GCPIntegration
 }
+
+type AzureIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+
+	ID uint `json:"id"`
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The Azure client ID that this is linked to
+	AzureClientID string `json:"azure_client_id"`
+
+	// The Azure subscription ID that this is linked to
+	AzureSubscriptionID string `json:"azure_subscription_id"`
+
+	// The Azure tenant ID that this is linked to
+	AzureTenantID string `json:"azure_tenant_id"`
+}
+
+type CreateAzureRequest struct {
+	AzureClientID       string `json:"azure_client_id" form:"required"`
+	AzureSubscriptionID string `json:"azure_subscription_id" form:"required"`
+	AzureTenantID       string `json:"azure_tenant_id" form:"required"`
+	ServicePrincipalKey string `json:"service_principal_key" form:"required"`
+}
+
+type CreateAzureResponse struct {
+	*AzureIntegration
+}

+ 9 - 0
api/types/registry.go

@@ -27,6 +27,9 @@ type Registry struct {
 	// The AWS integration that was used to create or connect the registry
 	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
 
+	// The Azure integration that was used to create or connect the registry
+	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
+
 	// The GCP integration that was used to create or connect the registry
 	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
 
@@ -73,6 +76,7 @@ type RegistryService string
 const (
 	GCR       RegistryService = "gcr"
 	ECR       RegistryService = "ecr"
+	ACR       RegistryService = "acr"
 	DOCR      RegistryService = "docr"
 	DockerHub RegistryService = "dockerhub"
 )
@@ -86,6 +90,11 @@ type CreateRegistryRequest struct {
 	AWSIntegrationID   uint   `json:"aws_integration_id"`
 	DOIntegrationID    uint   `json:"do_integration_id"`
 	BasicIntegrationID uint   `json:"basic_integration_id"`
+	AzureIntegrationID uint   `json:"azure_integration_id"`
+
+	// Additional Azure-specific fields
+	ACRResourceGroupName string `json:"acr_resource_group_name"`
+	ACRName              string `json:"acr_name"`
 }
 
 type CreateRegistryRepositoryRequest struct {

+ 1 - 1
build/Dockerfile.osx

@@ -1,4 +1,4 @@
-ARG GO_VERSION=1.17
+ARG GO_VERSION=1.18
 
 FROM golang:${GO_VERSION}
 

+ 1 - 1
build/Dockerfile.win

@@ -1,4 +1,4 @@
-ARG GO_VERSION=1.17
+ARG GO_VERSION=1.18
 
 FROM golang:${GO_VERSION}
 

+ 1 - 0
cli/cmd/apply.go

@@ -104,6 +104,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []str
 	worker.RegisterDriver("update-config", preview.NewUpdateConfigDriver)
 	worker.RegisterDriver("random-string", preview.NewRandomStringDriver)
 	worker.RegisterDriver("env-group", preview.NewEnvGroupDriver)
+	worker.RegisterDriver("os-env", preview.NewOSEnvDriver)
 
 	worker.SetDefaultDriver("deploy")
 

+ 10 - 25
cli/cmd/delete.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"strconv"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
@@ -112,38 +113,22 @@ func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []st
 		return fmt.Errorf("cluster id must be set")
 	}
 
-	var environmentID string
-	var gitRepoName string
-	var gitRepoOwner string
-	var gitPRNumber string
+	var deploymentID uint
 
-	if envID := os.Getenv("PORTER_ENVIRONMENT_ID"); envID != "" {
-		environmentID = envID
-	} else {
-		return fmt.Errorf("Environment ID must be defined, set by PORTER_ENVIRONMENT_ID")
-	}
-
-	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
-		gitRepoName = repoName
-	} else {
-		return fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
-	}
+	if deplIDStr := os.Getenv("PORTER_DEPLOYMENT_ID"); deplIDStr != "" {
+		deplID, err := strconv.ParseUint(deplIDStr, 10, 32)
 
-	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-		gitRepoOwner = repoOwner
-	} else {
-		return fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
-	}
+		if err != nil {
+			return fmt.Errorf("error parsing deployment ID: %s", deplIDStr)
+		}
 
-	if prNumber := os.Getenv("PORTER_PR_NUMBER"); prNumber != "" {
-		gitPRNumber = prNumber
+		deploymentID = uint(deplID)
 	} else {
-		return fmt.Errorf("Pull request number must be defined, set by PORTER_PR_NUMBER")
+		return fmt.Errorf("Deployment ID must be defined, set by PORTER_DEPLOYMENT_ID")
 	}
 
 	return client.DeleteDeployment(
-		context.Background(), projectID, clusterID, environmentID,
-		gitRepoOwner, gitRepoName, gitPRNumber,
+		context.Background(), projectID, clusterID, deploymentID,
 	)
 }
 

+ 30 - 0
cli/cmd/docker/auth.go

@@ -55,6 +55,8 @@ func (a *AuthGetter) GetCredentials(serverURL string) (user string, secret strin
 		return a.GetDOCRCredentials(serverURL, a.ProjectID)
 	} else if strings.Contains(serverURL, "index.docker.io") {
 		return a.GetDockerHubCredentials(serverURL, a.ProjectID)
+	} else if strings.Contains(serverURL, "azurecr.io") {
+		return a.GetACRCredentials(serverURL, a.ProjectID)
 	}
 
 	return a.GetECRCredentials(serverURL, a.ProjectID)
@@ -204,6 +206,34 @@ func (a *AuthGetter) GetDockerHubCredentials(serverURL string, projID uint) (use
 	return decodeDockerToken(token)
 }
 
+func (a *AuthGetter) GetACRCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	cachedEntry := a.Cache.Get(serverURL)
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		// get a token from the server
+		tokenResp, err := a.Client.GetACRAuthorizationToken(context.Background(), projID)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		a.Cache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return decodeDockerToken(token)
+}
+
 func decodeDockerToken(token string) (string, string, error) {
 	decodedToken, err := base64.StdEncoding.DecodeString(token)
 

+ 1 - 1
cli/cmd/pack/pack.go

@@ -140,7 +140,7 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig, ca
 	}
 
 	if len(buildOpts.Buildpacks) > 0 && strings.HasPrefix(buildOpts.Builder, "heroku") {
-		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile@1.0.0")
+		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile@1.0.1")
 	}
 
 	return sharedPackClient.Build(context.Background(), buildOpts)

+ 44 - 0
cli/cmd/preview/os_env_driver.go

@@ -0,0 +1,44 @@
+package preview
+
+import (
+	"os"
+	"strings"
+
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+type OSEnvDriver struct {
+	output map[string]interface{}
+}
+
+func NewOSEnvDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	return &OSEnvDriver{
+		output: make(map[string]interface{}),
+	}, nil
+}
+
+func (d *OSEnvDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *OSEnvDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	for _, key := range os.Environ() {
+		keyVal := strings.Split(key, "=")
+
+		if len(keyVal) == 2 && keyVal[0] != "" && keyVal[1] != "" &&
+			strings.HasPrefix(keyVal[0], "PORTER_APPLY_") {
+			envName := strings.TrimPrefix(keyVal[0], "PORTER_APPLY_")
+
+			if len(envName) > 0 {
+				d.output[envName] = keyVal[1]
+			}
+		}
+	}
+
+	return resource, nil
+}
+
+func (d *OSEnvDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}

+ 74 - 0
cli/cmd/preview/push_image_driver.go

@@ -1,10 +1,15 @@
 package preview
 
 import (
+	"context"
 	"fmt"
+	"os"
+	"strings"
 
 	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
@@ -69,6 +74,75 @@ func (d *PushDriver) Apply(resource *models.Resource) (*models.Resource, error)
 		return nil, err
 	}
 
+	_, err = client.GetRelease(
+		context.Background(),
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+		d.target.AppName,
+	)
+
+	shouldCreate := err != nil
+
+	if shouldCreate {
+		regList, err := client.ListRegistries(context.Background(), d.target.Project)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var registryURL string
+
+		if len(*regList) == 0 {
+			return nil, fmt.Errorf("no registry found")
+		} else {
+			registryURL = (*regList)[0].URL
+		}
+
+		var repoSuffix string
+
+		if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+			if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+				repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-")
+			}
+		}
+
+		sharedOpts := &deploy.SharedOpts{
+			ProjectID: d.target.Project,
+			ClusterID: d.target.Cluster,
+			Namespace: d.target.Namespace,
+		}
+
+		createAgent := &deploy.CreateAgent{
+			Client: client,
+			CreateOpts: &deploy.CreateOpts{
+				SharedOpts:  sharedOpts,
+				ReleaseName: d.target.AppName,
+				RegistryURL: registryURL,
+				RepoSuffix:  repoSuffix,
+			},
+		}
+
+		regID, imageURL, err := createAgent.GetImageRepoURL(d.target.AppName, sharedOpts.Namespace)
+
+		if err != nil {
+			return nil, err
+		}
+
+		err = client.CreateRepository(
+			context.Background(),
+			sharedOpts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	err = agent.PushImage(d.config.Push.Image)
 	if err != nil {
 		return nil, err

+ 49 - 2
cli/cmd/preview/update_config_driver.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"strings"
 
 	"github.com/cli/cli/git"
 	"github.com/fatih/color"
@@ -27,6 +28,7 @@ type UpdateConfigDriverConfig struct {
 
 	UpdateConfig struct {
 		Image string
+		Tag   string
 	} `mapstructure:"update_config"`
 
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
@@ -93,9 +95,12 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 
 	shouldCreate := err != nil
 
-	// FIXME: give tag option in config build, but override if PORTER_TAG is present
 	tag := os.Getenv("PORTER_TAG")
 
+	if tag == "" {
+		tag = d.config.UpdateConfig.Tag
+	}
+
 	if tag == "" {
 		commit, err := git.LastCommit()
 
@@ -106,6 +111,28 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 		tag = commit.Sha[:7]
 	}
 
+	regList, err := client.ListRegistries(context.Background(), d.target.Project)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var registryURL string
+
+	if len(*regList) == 0 {
+		return nil, fmt.Errorf("no registry found")
+	} else {
+		registryURL = (*regList)[0].URL
+	}
+
+	var repoSuffix string
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+			repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-")
+		}
+	}
+
 	sharedOpts := &deploy.SharedOpts{
 		ProjectID:   d.target.Project,
 		ClusterID:   d.target.Cluster,
@@ -124,15 +151,35 @@ func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource,
 				SharedOpts:  sharedOpts,
 				Kind:        d.source.Name,
 				ReleaseName: d.target.AppName,
+				RegistryURL: registryURL,
+				RepoSuffix:  repoSuffix,
 			},
 		}
 
-		_, err := createAgent.CreateFromRegistry(d.config.UpdateConfig.Image, d.config.Values)
+		regID, imageURL, err := createAgent.GetImageRepoURL(d.target.AppName, sharedOpts.Namespace)
 
 		if err != nil {
 			return nil, err
 		}
 
+		err = client.CreateRepository(
+			context.Background(),
+			sharedOpts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		_, err = createAgent.CreateFromRegistry(d.config.UpdateConfig.Image, d.config.Values)
+
+		if err != nil {
+			return nil, err
+		}
 	} else {
 		updateAgent, err := deploy.NewDeployAgent(client, d.target.AppName, &deploy.DeployOpts{
 			SharedOpts: sharedOpts,

文件差異過大導致無法顯示
+ 14098 - 1
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -26,6 +26,7 @@
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",
     "color": "^4.2.3",
+    "cohere-sentry": "^1.0.1",
     "core-js": "^3.16.1",
     "cron-parser": "^4.3.0",
     "cron-validator": "^1.3.1",

+ 279 - 0
dashboard/src/assets/devicons-name-list.ts

@@ -0,0 +1,279 @@
+export const DeviconsNameList = [
+  { name: "adonisjs" },
+  { name: "aftereffects" },
+  { name: "amazonwebservices" },
+  { name: "android" },
+  { name: "androidstudio" },
+  { name: "aarch64" },
+  { name: "angularjs" },
+  { name: "ansible" },
+  { name: "apache" },
+  { name: "apachekafka" },
+  { name: "appcelerator" },
+  { name: "apple" },
+  { name: "appwrite" },
+  { name: "arduino" },
+  { name: "atom" },
+  { name: "azure" },
+  { name: "babel" },
+  { name: "backbonejs" },
+  { name: "bamboo" },
+  { name: "bash" },
+  { name: "behance" },
+  { name: "bitbucket" },
+  { name: "bootstrap" },
+  { name: "bulma" },
+  { name: "bower" },
+  { name: "c" },
+  { name: "cakephp" },
+  { name: "canva" },
+  { name: "centos" },
+  { name: "ceylon" },
+  { name: "chrome" },
+  { name: "circleci" },
+  { name: "clojure" },
+  { name: "cmake" },
+  { name: "clojurescript" },
+  { name: "codecov" },
+  { name: "codeigniter" },
+  { name: "codepen" },
+  { name: "coffeescript" },
+  { name: "composer" },
+  { name: "confluence" },
+  { name: "couchdb" },
+  { name: "cplusplus" },
+  { name: "csharp" },
+  { name: "css3" },
+  { name: "cucumber" },
+  { name: "crystal" },
+  { name: "d3js" },
+  { name: "dart" },
+  { name: "debian" },
+  { name: "denojs" },
+  { name: "devicon" },
+  { name: "django" },
+  { name: "docker" },
+  { name: "doctrine" },
+  { name: "dot-net" },
+  { name: "dotnetcore" },
+  { name: "drupal" },
+  { name: "digitalocean" },
+  { name: "discordjs" },
+  { name: "electron" },
+  { name: "eleventy" },
+  { name: "elixir" },
+  { name: "elm" },
+  { name: "ember" },
+  { name: "embeddedc" },
+  { name: "erlang" },
+  { name: "eslint" },
+  { name: "express" },
+  { name: "facebook" },
+  { name: "feathersjs" },
+  { name: "figma" },
+  { name: "filezilla" },
+  { name: "firebase" },
+  { name: "firefox" },
+  { name: "flask" },
+  { name: "flutter" },
+  { name: "foundation" },
+  { name: "fsharp" },
+  { name: "gatling" },
+  { name: "gatsby" },
+  { name: "rect" },
+  { name: "gcc" },
+  { name: "gentoo" },
+  { name: "gimp" },
+  { name: "git" },
+  { name: "github" },
+  { name: "gitlab" },
+  { name: "gitter" },
+  { name: "go" },
+  { name: "google" },
+  { name: "googlecloud" },
+  { name: "gradle" },
+  { name: "grafana" },
+  { name: "grails" },
+  { name: "graphql" },
+  { name: "groovy" },
+  { name: "grunt" },
+  { name: "gulp" },
+  { name: "godot" },
+  { name: "haskell" },
+  { name: "handlebars" },
+  { name: "haxe" },
+  { name: "heroku" },
+  { name: "html5" },
+  { name: "hugo" },
+  { name: "ie10" },
+  { name: "ifttt" },
+  { name: "illustrator" },
+  { name: "inkscape" },
+  { name: "intellij" },
+  { name: "ionic" },
+  { name: "jamstack" },
+  { name: "jasmine" },
+  { name: "java" },
+  { name: "javascript" },
+  { name: "jeet" },
+  { name: "jest" },
+  { name: "jenkins" },
+  { name: "jetbrains" },
+  { name: "jira" },
+  { name: "jquery" },
+  { name: "julia" },
+  { name: "jupyter" },
+  { name: "kaggle" },
+  { name: "karma" },
+  { name: "kotlin" },
+  { name: "knockout" },
+  { name: "krakenjs" },
+  { name: "kubernetes" },
+  { name: "labview" },
+  { name: "laravel" },
+  { name: "latex" },
+  { name: "less" },
+  { name: "linkedin" },
+  { name: "lua" },
+  { name: "linux" },
+  { name: "materialui" },
+  { name: "matlab" },
+  { name: "magento" },
+  { name: "markdown" },
+  { name: "maya" },
+  { name: "meteor" },
+  { name: "minitab" },
+  { name: "mocha" },
+  { name: "modx" },
+  { name: "mongodb" },
+  { name: "moodle" },
+  { name: "msdos" },
+  { name: "mysql" },
+  { name: "neo4j" },
+  { name: "nestjs" },
+  { name: "networkx" },
+  { name: "nextjs" },
+  { name: "nginx" },
+  { name: "nixos" },
+  { name: "nodejs" },
+  { name: "nodewebkit" },
+  { name: "npm" },
+  { name: "nuget" },
+  { name: "numpy" },
+  { name: "nuxtjs" },
+  { name: "objectivec" },
+  { name: "opera" },
+  { name: "ocaml" },
+  { name: "openal" },
+  { name: "opengl" },
+  { name: "opensuse" },
+  { name: "oracle" },
+  { name: "pandas" },
+  { name: "perl" },
+  { name: "phalcon" },
+  { name: "photoshop" },
+  { name: "php" },
+  { name: "phpstorm" },
+  { name: "podman" },
+  { name: "polygon" },
+  { name: "postgresql" },
+  { name: "premierepro" },
+  { name: "processing" },
+  { name: "protractor" },
+  { name: "putty" },
+  { name: "pycharm" },
+  { name: "python" },
+  { name: "pytorch" },
+  { name: "raspberrypi" },
+  { name: "phoenix" },
+  { name: "qt" },
+  { name: "r" },
+  { name: "rails" },
+  { name: "react" },
+  { name: "redhat" },
+  { name: "redis" },
+  { name: "redux" },
+  { name: "rocksdb" },
+  { name: "ruby" },
+  { name: "rubymine" },
+  { name: "rust" },
+  { name: "safari" },
+  { name: "salesforce" },
+  { name: "sdl" },
+  { name: "rstudio" },
+  { name: "sass" },
+  { name: "scala" },
+  { name: "selenium" },
+  { name: "sequelize" },
+  { name: "shopware" },
+  { name: "shotgrid" },
+  { name: "sketch" },
+  { name: "slack" },
+  { name: "socketio" },
+  { name: "solidity" },
+  { name: "sourcetree" },
+  { name: "spring" },
+  { name: "spss" },
+  { name: "sqlalchemy" },
+  { name: "sqlite" },
+  { name: "subversion" },
+  { name: "microsoftsqlserver" },
+  { name: "ssh" },
+  { name: "stylus" },
+  { name: "svelte" },
+  { name: "swift" },
+  { name: "symfony" },
+  { name: "storybook" },
+  { name: "tailwindcss" },
+  { name: "tensorflow" },
+  { name: "terraform" },
+  { name: "threejs" },
+  { name: "tomcat" },
+  { name: "tortoisegit" },
+  { name: "towergit" },
+  { name: "travis" },
+  { name: "thealgorithms" },
+  { name: "trello" },
+  { name: "twitter" },
+  { name: "typescript" },
+  { name: "typo3" },
+  { name: "ubuntu" },
+  { name: "unity" },
+  { name: "unix" },
+  { name: "unrealengine" },
+  { name: "uwsgi" },
+  { name: "vagrant" },
+  { name: "vim" },
+  { name: "visualstudio" },
+  { name: "vuejs" },
+  { name: "vuestorefront" },
+  { name: "vscode" },
+  { name: "webflow" },
+  { name: "weblate" },
+  { name: "webpack" },
+  { name: "webstorm" },
+  { name: "windows8" },
+  { name: "woocommerce" },
+  { name: "wordpress" },
+  { name: "xamarin" },
+  { name: "xcode" },
+  { name: "xd" },
+  { name: "yarn" },
+  { name: "yii" },
+  { name: "yunohost" },
+  { name: "zend" },
+  { name: "zig" },
+  { name: "pytest" },
+  { name: "opencv" },
+  { name: "fastapi" },
+  { name: "k3s" },
+  { name: "packer" },
+  { name: "anaconda" },
+  { name: "rspec" },
+  { name: "argocd" },
+  { name: "prometheus" },
+  { name: "blender" },
+  { name: "dropwizard" },
+  { name: "vuetify" },
+  { name: "fedora" },
+];

+ 47 - 31
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -13,7 +13,7 @@ import Modal from "../../../main/home/modals/Modal";
 import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
 import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
 import { hasSetValue } from "../utils";
-import _, { omit } from "lodash";
+import _, { isObject, omit } from "lodash";
 import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Loading from "components/Loading";
@@ -595,37 +595,46 @@ const ExpandableEnvGroup: React.FC<{
         {isExpanded && (
           <>
             <Buffer />
-            {Object.entries(envGroup.variables || {})?.map(
-              ([key, value], i: number) => {
-                // Preprocess non-string env values set via raw Helm values
-                if (typeof value === "object") {
-                  value = JSON.stringify(value);
-                } else {
-                  value = String(value);
-                }
-
-                return (
-                  <InputWrapper key={i}>
-                    <Input
-                      placeholder="ex: key"
-                      width="270px"
-                      value={key}
-                      disabled
-                    />
-                    <Spacer />
-                    <Input
-                      placeholder="ex: value"
-                      width="270px"
-                      value={value}
-                      disabled
-                      type={
-                        value.includes("PORTERSECRET") ? "password" : "text"
-                      }
-                    />
-                  </InputWrapper>
-                );
-              }
+            {isObject(envGroup.variables) ? (
+              <>
+                {Object.entries(envGroup.variables || {})?.map(
+                  ([key, value], i: number) => {
+                    // Preprocess non-string env values set via raw Helm values
+                    if (typeof value === "object") {
+                      value = JSON.stringify(value);
+                    } else {
+                      value = String(value);
+                    }
+
+                    return (
+                      <InputWrapper key={i}>
+                        <Input
+                          placeholder="ex: key"
+                          width="270px"
+                          value={key}
+                          disabled
+                        />
+                        <Spacer />
+                        <Input
+                          placeholder="ex: value"
+                          width="270px"
+                          value={value}
+                          disabled
+                          type={
+                            value.includes("PORTERSECRET") ? "password" : "text"
+                          }
+                        />
+                      </InputWrapper>
+                    );
+                  }
+                )}
+              </>
+            ) : (
+              <NoVariablesTextWrapper>
+                This env group has no variables yet
+              </NoVariablesTextWrapper>
             )}
+
             <Br />
           </>
         )}
@@ -873,3 +882,10 @@ const ActionButton = styled.button`
     font-size: 20px;
   }
 `;
+
+const NoVariablesTextWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff99;
+`;

+ 10 - 9
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -1,3 +1,4 @@
+import { DeviconsNameList } from "assets/devicons-name-list";
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
 import SelectRow from "components/form-components/SelectRow";
@@ -12,8 +13,6 @@ const DEFAULT_BUILDER_NAME = "heroku";
 const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
 const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
 
-const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
-
 type BuildConfig = {
   builder: string;
   buildpacks: string[];
@@ -179,9 +178,16 @@ export const BuildpackSelection: React.FC<{
     action: "remove" | "add"
   ) => {
     return buildpacks?.map((buildpack) => {
-      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
+
       let disableIcon = false;
-      if (URLRegex.test(buildpack.buildpack)) {
+      if (!devicon) {
         disableIcon = true;
       }
 
@@ -314,11 +320,6 @@ export const AddCustomBuildpackForm: React.FC<{
   const [error, setError] = useState(false);
 
   const handleAddCustomBuildpack = () => {
-    if (!URLRegex.test(buildpackUrl)) {
-      setError(true);
-      return;
-    }
-
     const buildpack: Buildpack = {
       buildpack: buildpackUrl,
       name: buildpackUrl,

+ 123 - 176
dashboard/src/hosted.index.html

@@ -1,186 +1,133 @@
 <!DOCTYPE html>
 <html lang="en">
-  <head>
-    <title>Porter | Dashboard</title>
 
-    <script>
-      !(function () {
-        var e = (window.Cohere = window.Cohere || []);
-        if (e.invoked) console.error("Tried to load Cohere twice");
-        else {
-          (e.invoked = !0),
-            (e.snippet = "0.2"),
-            (e.methods = [
-              "init",
-              "identify",
-              "stop",
-              "showCode",
-              "getSessionUrl",
-              "makeCall",
-              "addCallStatusListener",
-              "removeCallStatusListener",
-              "widget",
-            ]),
-            e.methods.forEach(function (o) {
-              e[o] = function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(o), e.push(t);
-              };
-            });
-          var o = document.createElement("script");
-          (o.type = "text/javascript"),
-            (o.async = !0),
-            (o.src = "https://static.cohere.so/main.js"),
-            (o.crossOrigin = "anonymous");
-          var t = document.getElementsByTagName("script")[0];
-          t.parentNode.insertBefore(o, t);
-        }
-      })();
-      window.Cohere.init("<%= htmlWebpackPlugin.options.cohereKey %>");
-    </script>
+<head>
+  <title>Porter | Dashboard</title>
 
-    <script>
-      window.intercomSettings = {
-        app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
-        custom_launcher_selector: "#intercom_help",
-      };
-    </script>
+  <script>
+    window.intercomSettings = {
+      app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
+      custom_launcher_selector: "#intercom_help",
+    };
+  </script>
 
-    <script>
-      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-      (function () {
-        var w = window;
-        var ic = w.Intercom;
-        if (typeof ic === "function") {
-          ic("reattach_activator");
-          ic("update", w.intercomSettings);
+  <script>
+    // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+    (function () {
+      var w = window;
+      var ic = w.Intercom;
+      if (typeof ic === "function") {
+        ic("reattach_activator");
+        ic("update", w.intercomSettings);
+      } else {
+        var d = document;
+        var i = function () {
+          i.c(arguments);
+        };
+        i.q = [];
+        i.c = function (args) {
+          i.q.push(args);
+        };
+        w.Intercom = i;
+        var l = function () {
+          var s = d.createElement("script");
+          s.type = "text/javascript";
+          s.async = true;
+          s.src = "<%= htmlWebpackPlugin.options.intercomSrc %>";
+          var x = d.getElementsByTagName("script")[0];
+          x.parentNode.insertBefore(s, x);
+        };
+        if (document.readyState === "complete") {
+          l();
+        } else if (w.attachEvent) {
+          w.attachEvent("onload", l);
         } else {
-          var d = document;
-          var i = function () {
-            i.c(arguments);
-          };
-          i.q = [];
-          i.c = function (args) {
-            i.q.push(args);
-          };
-          w.Intercom = i;
-          var l = function () {
-            var s = d.createElement("script");
-            s.type = "text/javascript";
-            s.async = true;
-            s.src = "<%= htmlWebpackPlugin.options.intercomSrc %>";
-            var x = d.getElementsByTagName("script")[0];
-            x.parentNode.insertBefore(s, x);
-          };
-          if (document.readyState === "complete") {
-            l();
-          } else if (w.attachEvent) {
-            w.attachEvent("onload", l);
-          } else {
-            w.addEventListener("load", l, false);
-          }
+          w.addEventListener("load", l, false);
         }
-      })();
-    </script>
+      }
+    })();
+  </script>
 
-    <script>
-      !(function () {
-        var analytics = (window.analytics = window.analytics || []);
-        if (!analytics.initialize)
-          if (analytics.invoked)
-            window.console &&
-              console.error &&
-              console.error("Segment snippet included twice.");
-          else {
-            analytics.invoked = !0;
-            analytics.methods = [
-              "trackSubmit",
-              "trackClick",
-              "trackLink",
-              "trackForm",
-              "pageview",
-              "identify",
-              "reset",
-              "group",
-              "track",
-              "ready",
-              "alias",
-              "debug",
-              "page",
-              "once",
-              "off",
-              "on",
-              "addSourceMiddleware",
-              "addIntegrationMiddleware",
-              "setAnonymousId",
-              "addDestinationMiddleware",
-            ];
-            analytics.factory = function (e) {
-              return function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(e);
-                analytics.push(t);
-                return analytics;
-              };
-            };
-            for (var e = 0; e < analytics.methods.length; e++) {
-              var key = analytics.methods[e];
-              analytics[key] = analytics.factory(key);
-            }
-            analytics.load = function (key, e) {
-              var t = document.createElement("script");
-              t.type = "text/javascript";
-              t.async = !0;
-              t.src =
-                "https://cdn.segment.com/analytics.js/v1/" +
-                key +
-                "/analytics.min.js";
-              var n = document.getElementsByTagName("script")[0];
-              n.parentNode.insertBefore(t, n);
-              analytics._loadOptions = e;
+  <script>
+    !(function () {
+      var analytics = (window.analytics = window.analytics || []);
+      if (!analytics.initialize)
+        if (analytics.invoked)
+          window.console &&
+            console.error &&
+            console.error("Segment snippet included twice.");
+        else {
+          analytics.invoked = !0;
+          analytics.methods = [
+            "trackSubmit",
+            "trackClick",
+            "trackLink",
+            "trackForm",
+            "pageview",
+            "identify",
+            "reset",
+            "group",
+            "track",
+            "ready",
+            "alias",
+            "debug",
+            "page",
+            "once",
+            "off",
+            "on",
+            "addSourceMiddleware",
+            "addIntegrationMiddleware",
+            "setAnonymousId",
+            "addDestinationMiddleware",
+          ];
+          analytics.factory = function (e) {
+            return function () {
+              var t = Array.prototype.slice.call(arguments);
+              t.unshift(e);
+              analytics.push(t);
+              return analytics;
             };
-            analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
-            analytics.SNIPPET_VERSION = "4.13.2";
-            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
-            analytics.page();
+          };
+          for (var e = 0; e < analytics.methods.length; e++) {
+            var key = analytics.methods[e];
+            analytics[key] = analytics.factory(key);
           }
-      })();
-    </script>
-    <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
-    <meta
-      name="description"
-      content="Kubernetes powered PaaS that runs in your own cloud."
-    />
-    <meta property="og:title" content="Porter" />
-    <meta
-      property="og:image"
-      content="https://i.ibb.co/52g2g7C/porter-wide.png"
-    />
-    <meta
-      property="og:description"
-      content="Kubernetes powered PaaS that runs in your own cloud."
-    />
-    <meta property="og:url" content="https://porter.run" />
-    <link
-      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
-      rel="stylesheet"
-    />
-    <link
-      href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css"
-      rel="stylesheet"
-    />
-    <link
-      href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
-      rel="stylesheet"
-    />
-    <!-- Coding languages icons -->
-    <link
-      rel="stylesheet"
-      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
-    />
-  </head>
-  <body>
-    <div id="output"></div>
-    <div id="modal-root"></div>
-  </body>
-</html>
+          analytics.load = function (key, e) {
+            var t = document.createElement("script");
+            t.type = "text/javascript";
+            t.async = !0;
+            t.src =
+              "https://cdn.segment.com/analytics.js/v1/" +
+              key +
+              "/analytics.min.js";
+            var n = document.getElementsByTagName("script")[0];
+            n.parentNode.insertBefore(t, n);
+            analytics._loadOptions = e;
+          };
+          analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
+          analytics.SNIPPET_VERSION = "4.13.2";
+          analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
+          analytics.page();
+        }
+    })();
+  </script>
+  <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
+  <meta name="description" content="Kubernetes powered PaaS that runs in your own cloud." />
+  <meta property="og:title" content="Porter" />
+  <meta property="og:image" content="https://i.ibb.co/52g2g7C/porter-wide.png" />
+  <meta property="og:description" content="Kubernetes powered PaaS that runs in your own cloud." />
+  <meta property="og:url" content="https://porter.run" />
+  <link href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600" rel="stylesheet" />
+  <link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css" rel="stylesheet" />
+  <link href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
+    rel="stylesheet" />
+  <!-- Coding languages icons -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css" />
+</head>
+
+<body>
+  <div id="output"></div>
+  <div id="modal-root"></div>
+</body>
+
+</html>

+ 6 - 0
dashboard/src/index.tsx

@@ -3,6 +3,7 @@ import "regenerator-runtime/runtime";
 
 import * as React from "react";
 import * as ReactDOM from "react-dom";
+import Cohere from "cohere-js";
 import App from "./App";
 import { SetupSentry } from "shared/error_handling/sentry/setup";
 import { EnableErrorHandling } from "shared/error_handling/window_error_handling";
@@ -12,6 +13,11 @@ declare global {
     analytics: any;
   }
 }
+
+if (process.env.ENABLE_COHERE && process.env.COHERE_API_KEY) {
+  Cohere.init(process.env.COHERE_API_KEY);
+}
+
 if (process.env.ENABLE_SENTRY) {
   SetupSentry();
 }

+ 1 - 6
dashboard/src/main/Main.tsx

@@ -4,11 +4,6 @@ import { Route, Redirect, Switch } from "react-router-dom";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import Cohere from "cohere-js";
-
-if (window.location.href.includes("dashboard.getporter.dev")) {
-  Cohere.init(process.env.COHERE_API_KEY);
-}
-
 import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
 import Login from "./auth/Login";
@@ -47,7 +42,7 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .then((res) => {
         if (res && res?.data) {
-          if (window.location.href.includes("dashboard.getporter.dev")) {
+          if (process.env.ENABLE_COHERE) {
             Cohere.identify(res?.data?.id, {
               displayName: res?.data?.email,
               email: res?.data?.email,

+ 9 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -15,13 +15,9 @@ import {
 } from "shared/types";
 import styled, { keyframes } from "styled-components";
 import yaml from "js-yaml";
-import DynamicLink from "components/DynamicLink";
 import { AxiosError } from "axios";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
-
-const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
-const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
-const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+import { DeviconsNameList } from "assets/devicons-name-list";
 
 type Buildpack = {
   name: string;
@@ -565,13 +561,16 @@ const BuildpackConfigSection: React.FC<{
     }
 
     return buildpacks?.map((buildpack, i) => {
-      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
 
       let disableIcon = false;
-      if (
-        URLRegex.test(buildpack.buildpack) &&
-        !buildpack.buildpack.includes("gcr.io/paketo-buildpacks")
-      ) {
+      if (!devicon) {
         disableIcon = true;
       }
 

+ 16 - 6
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -20,6 +20,7 @@ import {
 } from "components/porter-form/types";
 import Helper from "components/form-components/Helper";
 import DocsHelper from "components/DocsHelper";
+import { isObject } from "lodash";
 
 type PropsType = {
   namespace: string;
@@ -157,6 +158,9 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
   };
 
   potentiallyOverriddenKeys(incoming: Record<string, string>): KeyValue[] {
+    if (!incoming) {
+      return [];
+    }
     // console.log(incoming, this.props.existingValues);
     return Object.entries(incoming)
       .filter(([key]) => this.props.existingValues[key])
@@ -227,12 +231,18 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           {this.state.selectedEnvGroup && (
             <SidebarSection>
               <GroupEnvPreview>
-                {Object.entries(this.state.selectedEnvGroup.variables)
-                  .map(
-                    ([key, value]) =>
-                      `${key}=${formattedEnvironmentValue(value)}`
-                  )
-                  .join("\n")}
+                {isObject(this.state.selectedEnvGroup.variables) ? (
+                  <>
+                    {Object.entries(this.state.selectedEnvGroup.variables || {})
+                      .map(
+                        ([key, value]) =>
+                          `${key}=${formattedEnvironmentValue(value)}`
+                      )
+                      .join("\n")}
+                  </>
+                ) : (
+                  <>This environment group has no variables</>
+                )}
               </GroupEnvPreview>
               {clashingKeys?.length > 0 && (
                 <>

+ 24 - 13
dashboard/src/main/home/onboarding/Routes.tsx

@@ -1,5 +1,6 @@
 import React from "react";
 import { Route, Switch } from "react-router";
+import PorterErrorBoundary from "shared/error_handling/PorterErrorBoundary";
 import { OFState } from "./state";
 import ConnectRegistry from "./steps/ConnectRegistry/ConnectRegistry";
 import ConnectSource from "./steps/ConnectSource";
@@ -8,19 +9,29 @@ import ProvisionResources from "./steps/ProvisionResources/ProvisionResources";
 export const Routes = () => {
   return (
     <>
-      <Switch>
-        <Route path={`/onboarding/source`}>
-          <ConnectSource
-            onSuccess={(data) => OFState.actions.nextStep("continue", data)}
-          />
-        </Route>
-        <Route path={["/onboarding/registry/:step?"]}>
-          <ConnectRegistry />
-        </Route>
-        <Route path={[`/onboarding/provision/:step?`]}>
-          <ProvisionResources />
-        </Route>
-      </Switch>
+      <PorterErrorBoundary
+        errorBoundaryLocation="onboarding"
+        tags={{ scope: "onboarding" }}
+      >
+        <Switch>
+          <Route path={`/onboarding/source`}>
+            <ConnectSource
+              onSuccess={(data) => OFState.actions.nextStep("continue", data)}
+            />
+          </Route>
+          <Route path={["/onboarding/registry/:step?"]}>
+            <ConnectRegistry />
+          </Route>
+          <Route path={[`/onboarding/provision/:step?`]}>
+            <PorterErrorBoundary
+              errorBoundaryLocation="onboarding.provision_resources"
+              tags={{ scope: "onboarding.provision_resources" }}
+            >
+              <ProvisionResources />
+            </PorterErrorBoundary>
+          </Route>
+        </Switch>
+      </PorterErrorBoundary>
     </>
   );
 };

+ 45 - 59
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -1,7 +1,8 @@
 import Helper from "components/form-components/Helper";
 import SaveButton from "components/SaveButton";
 import TitleSection from "components/TitleSection";
-import React, { useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
+import Cohere from "cohere-js";
 import { useParams } from "react-router";
 import styled from "styled-components";
 import ProviderSelector, {
@@ -22,12 +23,11 @@ import api from "shared/api";
 import Placeholder from "components/Placeholder";
 import Loading from "components/Loading";
 import MultiSaveButton from "components/MultiSaveButton";
+import buildLogger from "shared/error_handling/logger";
 
-type Props = {};
+const ProvisionResourcesLogger = buildLogger("onboarding.provision_resources");
 
-type SaveButtonOptions = "retry" | "delete_all" | "back";
-
-const ProvisionResources: React.FC<Props> = () => {
+const ProvisionResources: React.FC<{}> = () => {
   const snap = useSnapshot(OFState);
   const { step } = useParams<{ step: any }>();
   const [infraStatus, setInfraStatus] = useState<{
@@ -35,10 +35,6 @@ const ProvisionResources: React.FC<Props> = () => {
     errored_infras: number[];
     description?: string;
   }>(null);
-  const [
-    failedSaveButtonOption,
-    setFailedSaveButtonOption,
-  ] = useState<SaveButtonOptions>("retry");
 
   const [isLoading, setIsLoading] = useState(false);
 
@@ -134,49 +130,6 @@ const ProvisionResources: React.FC<Props> = () => {
       });
   };
 
-  const getFailedSaveButton = () => {
-    switch (failedSaveButtonOption) {
-      case "retry":
-        return (
-          <SaveButton
-            text="Retry"
-            disabled={false}
-            onClick={retryFailedInfras}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-          />
-        );
-      case "delete_all":
-        return (
-          <SaveButton
-            text="Delete All Infrastructure"
-            disabled={false}
-            onClick={deleteAllInfras}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-          />
-        );
-      case "back":
-        return (
-          <SaveButton
-            text="Configure Settings"
-            disabled={false}
-            onClick={() => {
-              handleGoBack("");
-            }}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="right"
-            saveText=""
-          />
-        );
-    }
-  };
-
   const renderSaveButton = () => {
     if (typeof infraStatus?.hasError !== "boolean") {
       return;
@@ -237,15 +190,15 @@ const ProvisionResources: React.FC<Props> = () => {
     }
   };
 
-  const getDescription = () => {
-    if (infraStatus && infraStatus.hasError) {
+  const description = useMemo(() => {
+    if (infraStatus?.hasError) {
       return "Error while creating infrastructure. Please select an option below to continue.";
     }
 
     return "Note: Provisioning can take up to 15 minutes.";
-  };
+  }, [infraStatus]);
 
-  const getFilterOpts = (): string[] => {
+  const filterOpts = useMemo(() => {
     switch (provider) {
       case "aws":
         return ["eks", "ecr"];
@@ -256,7 +209,40 @@ const ProvisionResources: React.FC<Props> = () => {
     }
 
     return [];
-  };
+  }, [provider]);
+
+  useEffect(() => {
+    if (!infraStatus) return;
+
+    if (typeof infraStatus.hasError !== "boolean") return;
+
+    if (infraStatus.hasError) {
+      Cohere.widget("show");
+      Cohere.widget("expand");
+
+      const cause = new Error(
+        JSON.stringify({
+          description: infraStatus.description,
+          errored_infras: infraStatus.errored_infras,
+        })
+      );
+
+      ProvisionResourcesLogger.critical(
+        new Error(
+          `Provisioner error detected ${snap.StateHandler.project.id}`,
+          { cause }
+        )
+      );
+    } else {
+      Cohere.widget("hide");
+    }
+  }, [infraStatus]);
+
+  useEffect(() => {
+    return () => {
+      Cohere.widget("hide");
+    };
+  }, []);
 
   const Content = () => {
     switch (step) {
@@ -276,7 +262,7 @@ const ProvisionResources: React.FC<Props> = () => {
           <>
             <StatusPage
               project_id={project?.id}
-              filter={getFilterOpts()}
+              filter={filterOpts}
               setInfraStatus={setInfraStatus}
               filterLatest
               auto_expanded
@@ -285,7 +271,7 @@ const ProvisionResources: React.FC<Props> = () => {
               can_delete={false}
             />
             <Br />
-            <Helper>{getDescription()}</Helper>
+            <Helper>{description}</Helper>
             {renderSaveButton()}
           </>
         );

+ 16 - 4
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -14,7 +14,7 @@ import { Context } from "shared/Context";
 import ClusterSection from "./ClusterSection";
 import ProjectSectionContainer from "./ProjectSectionContainer";
 import { RouteComponentProps, withRouter } from "react-router";
-import { pushFiltered } from "shared/routing";
+import { getQueryParam, pushFiltered } from "shared/routing";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { NavLink } from "react-router-dom";
 
@@ -115,8 +115,12 @@ class Sidebar extends Component<PropsType, StateType> {
               let pathNamespace = params.namespace;
               let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
+              if (!pathNamespace) {
+                pathNamespace = getQueryParam(this.props, "namespace");
+              }
+
               if (pathNamespace) {
-                search.concat(`&namespace=${pathNamespace}`);
+                search = search.concat(`&namespace=${pathNamespace}`);
               }
 
               return {
@@ -135,8 +139,12 @@ class Sidebar extends Component<PropsType, StateType> {
               let pathNamespace = params.namespace;
               let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
+              if (!pathNamespace) {
+                pathNamespace = getQueryParam(this.props, "namespace");
+              }
+
               if (pathNamespace) {
-                search.concat(`&namespace=${pathNamespace}`);
+                search = search.concat(`&namespace=${pathNamespace}`);
               }
 
               return {
@@ -155,8 +163,12 @@ class Sidebar extends Component<PropsType, StateType> {
               let pathNamespace = params.namespace;
               let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
 
+              if (!pathNamespace) {
+                pathNamespace = getQueryParam(this.props, "namespace");
+              }
+
               if (pathNamespace) {
-                search.concat(`&namespace=${pathNamespace}`);
+                search = search.concat(`&namespace=${pathNamespace}`);
               }
 
               return {

+ 65 - 0
dashboard/src/shared/error_handling/logger.ts

@@ -0,0 +1,65 @@
+import * as Sentry from "@sentry/react";
+import Cohere from "cohere-js";
+import { isEmpty } from "lodash";
+
+type LogFunction = (error: Error, tags?: { [key: string]: string }) => void;
+type LogFunctions = {
+  [key in Sentry.Severity]: LogFunction;
+};
+
+type LogFunctionBuilder = (
+  scope: string,
+  severity: Sentry.Severity
+) => LogFunction;
+
+const logFunctionBuilder: LogFunctionBuilder = (scope, severity) => (
+  error,
+  tags
+) => {
+  if (process.env.ENABLE_COHERE) {
+    Cohere.getSessionUrl((sessionUrl) => {
+      Sentry.withScope((sentryScope) => {
+        sentryScope.setTag("scope", scope);
+        sentryScope.setTag("cohere_link", sessionUrl);
+        sentryScope.setLevel(severity);
+
+        if (!isEmpty(tags)) {
+          sentryScope.setTags(tags);
+        }
+
+        Sentry.captureException(error);
+      });
+    });
+  } else {
+    Sentry.withScope((sentryScope) => {
+      sentryScope.setTag("scope", scope);
+      sentryScope.setLevel(severity);
+
+      if (!isEmpty(tags)) {
+        sentryScope.setTags(tags);
+      }
+
+      Sentry.captureException(error);
+    });
+  }
+};
+
+function buildLogger(scope: string = "global") {
+  const logFunctions = Object.values(Sentry.Severity).reduce<LogFunctions>(
+    (acc, currentSeverity) => {
+      if (typeof currentSeverity === "string") {
+        acc[currentSeverity] = logFunctionBuilder(
+          scope,
+          Sentry.Severity.fromString(currentSeverity)
+        );
+      }
+
+      return acc;
+    },
+    {} as LogFunctions
+  );
+
+  return logFunctions;
+}
+
+export default buildLogger;

+ 24 - 1
dashboard/src/shared/error_handling/sentry/setup.ts

@@ -1,8 +1,13 @@
 import * as Sentry from "@sentry/react";
 import { Integrations } from "@sentry/tracing";
+import Cohere from "cohere-js";
+import CohereSentry from "cohere-sentry";
 
 const SENTRY_DSN = process.env.SENTRY_DSN;
 const SENTRY_ENV = process.env.SENTRY_ENV || "development";
+const COHERE_INTEGRATION = process.env.ENABLE_COHERE
+  ? [new CohereSentry()]
+  : [];
 
 export const SetupSentry = () => {
   if (!SENTRY_DSN) {
@@ -10,9 +15,27 @@ export const SetupSentry = () => {
   }
   Sentry.init({
     dsn: SENTRY_DSN,
-    integrations: [new Integrations.BrowserTracing()],
+    integrations: [new Integrations.BrowserTracing(), ...COHERE_INTEGRATION],
     environment: SENTRY_ENV,
     // Check out https://docs.sentry.io/platforms/javascript/guides/react/configuration/sampling/ for a more refined sample rate
     tracesSampleRate: 1,
   });
+
+  if (process.env.ENABLE_COHERE) {
+    const sessionUrlListener = (sessionUrl: string) => {
+      Sentry.configureScope((scope) => {
+        scope.addEventProcessor((event) => {
+          event.tags = {
+            ...event.tags,
+            cohere_link: `${sessionUrl}${
+              event.timestamp ? `?ts=${event.timestamp * 1000}` : ""
+            }`,
+          };
+
+          return event;
+        });
+      });
+    };
+    Cohere.addSessionUrlListener(sessionUrlListener);
+  }
 };

+ 13 - 2
dashboard/webpack.config.js

@@ -12,13 +12,25 @@ const TerserPlugin = require("terser-webpack-plugin");
 
 module.exports = () => {
   let env = dotenv.config().parsed;
+
   if (!env) {
     env = process.env;
   }
   const envKeys = Object.keys(env).reduce((prev, next) => {
-    prev[`process.env.${next}`] = JSON.stringify(env[next]);
+    const varName = `process.env.${next}`;
+    if (typeof env[next] !== "string") return prev;
+
+    if (env[next].toLowerCase() === "true") {
+      prev[varName] = true;
+    } else if (env[next].toLowerCase() === "false") {
+      prev[varName] = false;
+    } else {
+      prev[varName] = JSON.stringify(env[next]);
+    }
+
     return prev;
   }, {});
+
   // Check first the env file and if it's empty, check out the node env of the process.
   let isDevelopment = env.NODE_ENV !== "production";
   if (process.env.NODE_ENV !== env.NODE_ENV) {
@@ -32,7 +44,6 @@ module.exports = () => {
   if (env.IS_HOSTED) {
     htmlPluginOpts = {
       template: path.resolve(__dirname, "src", "hosted.index.html"),
-      cohereKey: `${env.COHERE_KEY}`,
       intercomAppId: `${env.INTERCOM_APP_ID}`,
       intercomSrc: `${process.env.INTERCOM_SRC}`,
       segmentWriteKey: `${process.env.SEGMENT_WRITE_KEY}`,

+ 1 - 1
docker/Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.17-alpine as base
+FROM golang:1.18-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc

+ 1 - 1
docker/cli.Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.17 as base
+FROM golang:1.18 as base
 WORKDIR /porter
 
 RUN apt-get update && apt-get install -y gcc musl-dev git make

+ 1 - 1
docker/dev.Dockerfile

@@ -1,6 +1,6 @@
 # Development environment
 # -----------------------
-FROM golang:1.17-alpine
+FROM golang:1.18-alpine
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git

+ 1 - 1
ee/docker/ee.Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.17-alpine as base
+FROM golang:1.18-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc

+ 1 - 1
ee/docker/provisioner.Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.17-alpine as base
+FROM golang:1.18-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git protoc

+ 10 - 0
ee/integrations/vault/types.go

@@ -48,6 +48,16 @@ type GetAWSCredentialData struct {
 	Data     *credentials.AWSCredential `json:"data"`
 }
 
+type GetAzureCredentialResponse struct {
+	*VaultGetResponse
+	Data *GetAzureCredentialData `json:"data"`
+}
+
+type GetAzureCredentialData struct {
+	Metadata *VaultMetadata               `json:"metadata"`
+	Data     *credentials.AzureCredential `json:"data"`
+}
+
 type CreatePolicyRequest struct {
 	Policy string `json:"policy"`
 }

+ 38 - 0
ee/integrations/vault/vault.go

@@ -147,6 +147,44 @@ func (c *Client) getAWSCredentialPath(awsIntegration *integrations.AWSIntegratio
 	)
 }
 
+func (c *Client) WriteAzureCredential(
+	azIntegration *integrations.AzureIntegration,
+	data *credentials.AzureCredential) error {
+	reqData := &CreateVaultSecretRequest{
+		Data: data,
+	}
+
+	return c.postRequest(fmt.Sprintf("/v1/%s", c.getAzureCredentialPath(azIntegration)), reqData, nil)
+}
+
+func (c *Client) GetAzureCredential(azIntegration *integrations.AzureIntegration) (*credentials.AzureCredential, error) {
+	resp := &GetAzureCredentialResponse{}
+
+	err := c.getRequest(fmt.Sprintf("/v1/%s", c.getAzureCredentialPath(azIntegration)), resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Data.Data, nil
+}
+
+func (c *Client) CreateAzureToken(azIntegration *integrations.AzureIntegration) (string, error) {
+	credPath := c.getAzureCredentialPath(azIntegration)
+	policyName := fmt.Sprintf("access-%d-azure-%d", azIntegration.ProjectID, azIntegration.ID)
+
+	return c.getToken(credPath, policyName)
+}
+
+func (c *Client) getAzureCredentialPath(azIntegration *integrations.AzureIntegration) string {
+	return fmt.Sprintf(
+		"kv/data/secret/%s/%d/azure/%d",
+		c.secretPrefix,
+		azIntegration.ProjectID,
+		azIntegration.ID,
+	)
+}
+
 const readOnlyPolicyTemplate = `path "%s" {
   capabilities = ["read"]
 }`

+ 42 - 32
go.mod

@@ -1,6 +1,6 @@
 module github.com/porter-dev/porter
 
-go 1.17
+go 1.18
 
 require (
 	cloud.google.com/go v0.99.0
@@ -8,7 +8,7 @@ require (
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation/v2 v2.0.3
-	github.com/buildpacks/pack v0.24.1
+	github.com/buildpacks/pack v0.26.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/digitalocean/godo v1.75.0
@@ -37,21 +37,21 @@ require (
 	github.com/mitchellh/mapstructure v1.4.3
 	github.com/moby/moby v20.10.6+incompatible
 	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
-	github.com/opencontainers/image-spec v1.0.2
+	github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799
 	github.com/pkg/errors v0.9.1
 	github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb
 	github.com/rs/zerolog v1.26.0
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
-	github.com/spf13/cobra v1.3.0
+	github.com/spf13/cobra v1.4.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.0
 	github.com/stretchr/testify v1.7.0
-	golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
-	golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3
+	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
+	golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
 	google.golang.org/api v0.62.0
-	google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac
-	google.golang.org/grpc v1.45.0
+	google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731
+	google.golang.org/grpc v1.46.0
 	google.golang.org/protobuf v1.28.0
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.22.3
@@ -74,6 +74,18 @@ require (
 )
 
 require (
+	github.com/Azure/azure-sdk-for-go v63.4.0+incompatible // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v0.23.1 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v0.5.0 // indirect
+	github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
+	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
+	github.com/kylelemons/godebug v1.1.0 // indirect
+	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
+)
+
+require (
+	github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.14.0
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
 	github.com/Azure/go-autorest v14.2.0+incompatible // indirect
 	github.com/Azure/go-autorest/autorest v0.11.20 // indirect
@@ -94,8 +106,7 @@ require (
 	github.com/apex/log v1.9.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
-	github.com/bits-and-blooms/bitset v1.2.0 // indirect
-	github.com/buildpacks/imgutil v0.0.0-20220310160537-4dd8bc60eaff // indirect
+	github.com/buildpacks/imgutil v0.0.0-20220425182719-2edb52457eb0 // indirect
 	github.com/buildpacks/lifecycle v0.14.0 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
@@ -104,23 +115,23 @@ require (
 	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
 	github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
 	github.com/containerd/cgroups v1.0.3 // indirect
-	github.com/containerd/containerd v1.6.2 // indirect
-	github.com/containerd/stargz-snapshotter/estargz v0.11.3 // indirect
+	github.com/containerd/containerd v1.6.3 // indirect
+	github.com/containerd/stargz-snapshotter/estargz v0.11.4 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/docker/go-units v0.4.0 // indirect
 	github.com/dustin/go-humanize v1.0.0 // indirect
-	github.com/emirpasic/gods v1.12.0 // indirect
-	github.com/envoyproxy/go-control-plane v0.10.1 // indirect
+	github.com/emirpasic/gods v1.18.1 // indirect
+	github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
 	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/fvbommel/sortorder v1.0.1 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
-	github.com/gdamore/tcell/v2 v2.4.0 // indirect
+	github.com/gdamore/tcell/v2 v2.5.1 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect
 	github.com/go-errors/errors v1.0.1 // indirect
 	github.com/go-logr/logr v1.2.2 // indirect
@@ -134,7 +145,7 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/btree v1.0.1 // indirect
-	github.com/google/go-cmp v0.5.7 // indirect
+	github.com/google/go-cmp v0.5.8 // indirect
 	github.com/google/go-containerregistry v0.8.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
@@ -168,8 +179,8 @@ require (
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
-	github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
-	github.com/klauspost/compress v1.15.1 // indirect
+	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/klauspost/compress v1.15.2 // indirect
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -181,7 +192,7 @@ require (
 	github.com/mailru/easyjson v0.7.6 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
-	github.com/mattn/go-runewidth v0.0.12 // indirect
+	github.com/mattn/go-runewidth v0.0.13 // indirect
 	github.com/mattn/go-sqlite3 v1.14.6 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
 	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
@@ -192,32 +203,31 @@ require (
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/moby/locker v1.0.1 // indirect
 	github.com/moby/spdystream v0.2.0 // indirect
-	github.com/moby/sys/mount v0.2.0 // indirect
-	github.com/moby/sys/mountinfo v0.5.0 // indirect
+	github.com/moby/sys/mount v0.3.2 // indirect
+	github.com/moby/sys/mountinfo v0.6.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/onsi/ginkgo v1.16.4 // indirect
-	github.com/onsi/gomega v1.18.1 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
-	github.com/opencontainers/runc v1.1.0 // indirect
-	github.com/opencontainers/selinux v1.10.0 // indirect
-	github.com/pelletier/go-toml v1.9.4 // indirect
+	github.com/opencontainers/runc v1.1.1 // indirect
+	github.com/opencontainers/selinux v1.10.1 // indirect
+	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/prometheus/client_golang v1.11.0 // indirect
+	github.com/prometheus/client_golang v1.11.1 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
 	github.com/prometheus/common v0.30.0 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
-	github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 // indirect
+	github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc // indirect
 	github.com/russross/blackfriday v1.5.2 // indirect
-	github.com/sabhiram/go-gitignore v0.0.0-20201211074657-223ce5d391b0 // indirect
+	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
-	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/sergi/go-diff v1.2.0 // indirect
 	github.com/shopspring/decimal v1.2.0 // indirect
 	github.com/sirupsen/logrus v1.8.1 // indirect
 	github.com/spf13/afero v1.6.0 // indirect
@@ -226,7 +236,7 @@ require (
 	github.com/src-d/gcfg v1.4.0 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
 	github.com/vbatts/tar-split v0.11.2 // indirect
-	github.com/xanzy/ssh-agent v0.3.0 // indirect
+	github.com/xanzy/ssh-agent v0.3.1 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
@@ -236,8 +246,8 @@ require (
 	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
 	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
-	golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect
-	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
+	golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
 	google.golang.org/appengine v1.6.7 // indirect

文件差異過大導致無法顯示
+ 193 - 90
go.sum


+ 2 - 17
internal/integrations/ci/actions/preview.go

@@ -290,23 +290,8 @@ func getPreviewDeleteActionYAML(opts *EnvOpts) ([]byte, error) {
 		On: map[string]interface{}{
 			"workflow_dispatch": map[string]interface{}{
 				"inputs": map[string]interface{}{
-					"environment_id": map[string]interface{}{
-						"description": "Environment ID",
-						"type":        "number",
-						"required":    true,
-					},
-					"repo_owner": map[string]interface{}{
-						"description": "Repository owner",
-						"type":        "string",
-						"required":    true,
-					},
-					"repo_name": map[string]interface{}{
-						"description": "Repository name",
-						"type":        "string",
-						"required":    true,
-					},
-					"pr_number": map[string]interface{}{
-						"description": "Pull request number",
+					"deployment_id": map[string]interface{}{
+						"description": "Deployment ID",
 						"type":        "number",
 						"required":    true,
 					},

+ 5 - 8
internal/integrations/ci/actions/steps.go

@@ -74,14 +74,11 @@ func getDeletePreviewEnvStep(serverURL, porterTokenSecretName string, projectID,
 		Name: "Delete Porter preview env",
 		Uses: fmt.Sprintf("%s@%s", deletePreviewActionName, actionVersion),
 		With: map[string]string{
-			"cluster":        fmt.Sprintf("%d", clusterID),
-			"host":           serverURL,
-			"project":        fmt.Sprintf("%d", projectID),
-			"token":          fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
-			"environment_id": "${{ github.event.inputs.environment_id }}",
-			"repo_owner":     "${{ github.repository_owner }}",
-			"repo_name":      repoName,
-			"pr_number":      "${{ github.event.inputs.pr_number }}",
+			"cluster":       fmt.Sprintf("%d", clusterID),
+			"host":          serverURL,
+			"project":       fmt.Sprintf("%d", projectID),
+			"token":         fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"deployment_id": "${{ github.event.inputs.deployment_id }}",
 		},
 		Timeout: 30,
 	}

+ 0 - 2
internal/kubernetes/prometheus/metrics.go

@@ -425,8 +425,6 @@ func createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelecti
 		kubeMetricsHPASelectorTwo,
 	)
 
-	fmt.Println("query is:")
-
 	return fmt.Sprintf(
 		`(%s * on(%s) %s) or (%s * on(%s) %s)`,
 		requestMemOne, hpaMetricName, targetMemUtilThresholdOne,

+ 56 - 0
internal/models/integrations/azure.go

@@ -0,0 +1,56 @@
+package integrations
+
+import (
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// AzureIntegration is an auth mechanism that uses a Azure service account principal to
+// authenticate
+type AzureIntegration struct {
+	gorm.Model
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The Azure client ID that this is linked to
+	AzureClientID string `json:"azure_client_id"`
+
+	// The Azure subscription ID that this is linked to
+	AzureSubscriptionID string `json:"azure_subscription_id"`
+
+	// The Azure tenant ID that this is linked to
+	AzureTenantID string `json:"azure_tenant_id"`
+
+	// ACR-specific fields
+	ACRTokenName         string `json:"acr_token_name"`
+	ACRResourceGroupName string `json:"acr_resource_group_name"`
+	ACRName              string `json:"acr_name"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// The Azure service principal key
+	ServicePrincipalSecret []byte `json:"service_principal_secret"`
+
+	// The ACR passwords, if set
+	ACRPassword1 []byte `json:"acr_password_1"`
+	ACRPassword2 []byte `json:"acr_password_2"`
+}
+
+func (a *AzureIntegration) ToAzureIntegrationType() *types.AzureIntegration {
+	return &types.AzureIntegration{
+		CreatedAt:           a.CreatedAt,
+		ID:                  a.ID,
+		UserID:              a.UserID,
+		ProjectID:           a.ProjectID,
+		AzureClientID:       a.AzureClientID,
+		AzureSubscriptionID: a.AzureSubscriptionID,
+		AzureTenantID:       a.AzureTenantID,
+	}
+}

+ 1 - 0
internal/models/project.go

@@ -55,6 +55,7 @@ type Project struct {
 	OAuthIntegrations []ints.OAuthIntegration `json:"oauth_integrations"`
 	AWSIntegrations   []ints.AWSIntegration   `json:"aws_integrations"`
 	GCPIntegrations   []ints.GCPIntegration   `json:"gcp_integrations"`
+	AzureIntegrations []ints.AzureIntegration `json:"azure_integrations"`
 
 	PreviewEnvsEnabled  bool
 	RDSDatabasesEnabled bool

+ 4 - 0
internal/models/registry.go

@@ -31,6 +31,7 @@ type Registry struct {
 
 	GCPIntegrationID   uint
 	AWSIntegrationID   uint
+	AzureIntegrationID uint
 	DOIntegrationID    uint
 	BasicIntegrationID uint
 
@@ -47,6 +48,8 @@ func (r *Registry) ToRegistryType() *types.Registry {
 		serv = types.GCR
 	} else if r.DOIntegrationID != 0 {
 		serv = types.DOCR
+	} else if r.AzureIntegrationID != 0 {
+		serv = types.ACR
 	} else if strings.Contains(r.URL, "index.docker.io") {
 		serv = types.DockerHub
 	}
@@ -67,6 +70,7 @@ func (r *Registry) ToRegistryType() *types.Registry {
 		InfraID:            r.InfraID,
 		GCPIntegrationID:   r.GCPIntegrationID,
 		AWSIntegrationID:   r.AWSIntegrationID,
+		AzureIntegrationID: r.AzureIntegrationID,
 		DOIntegrationID:    r.DOIntegrationID,
 		BasicIntegrationID: r.BasicIntegrationID,
 	}

+ 262 - 0
internal/registry/registry.go

@@ -10,6 +10,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/models"
@@ -24,6 +25,10 @@ import (
 	"github.com/digitalocean/godo"
 	"github.com/docker/cli/cli/config/configfile"
 	"github.com/docker/cli/cli/config/types"
+
+	"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry"
+
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
 )
 
 // Registry wraps the gorm Registry model
@@ -71,6 +76,10 @@ func (r *Registry) ListRepositories(
 		return r.listDOCRRepositories(repo, doAuth)
 	}
 
+	if r.AzureIntegrationID != 0 {
+		return r.listACRRepositories(repo)
+	}
+
 	if r.BasicIntegrationID != 0 {
 		return r.listPrivateRegistryRepositories(repo)
 	}
@@ -230,6 +239,174 @@ func (r *Registry) listECRRepositories(repo repository.Repository) ([]*ptypes.Re
 	return res, nil
 }
 
+func (r *Registry) listACRRepositories(repo repository.Repository) ([]*ptypes.RegistryRepository, error) {
+	az, err := repo.AzureIntegration().ReadAzureIntegration(
+		r.ProjectID,
+		r.AzureIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := &http.Client{}
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/v2/_catalog", r.URL),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(az.AzureClientID, string(az.ServicePrincipalSecret))
+
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	gcrResp := gcrRepositoryResp{}
+
+	if err := json.NewDecoder(resp.Body).Decode(&gcrResp); err != nil {
+		return nil, fmt.Errorf("Could not read Azure registry repositories: %v", err)
+	}
+
+	res := make([]*ptypes.RegistryRepository, 0)
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, repo := range gcrResp.Repositories {
+		res = append(res, &ptypes.RegistryRepository{
+			Name: repo,
+			URI:  strings.TrimPrefix(r.URL, "https://") + "/" + repo,
+		})
+	}
+
+	return res, nil
+}
+
+// Returns the username/password pair for the registry
+func (r *Registry) GetACRCredentials(repo repository.Repository) (string, string, error) {
+	az, err := repo.AzureIntegration().ReadAzureIntegration(
+		r.ProjectID,
+		r.AzureIntegrationID,
+	)
+
+	if err != nil {
+		return "", "", err
+	}
+
+	// if the passwords and name aren't set, generate them
+	if az.ACRTokenName == "" || len(az.ACRPassword1) == 0 {
+		az.ACRTokenName = "porter-acr-token"
+
+		// create an acr repo token
+		cred, err := azidentity.NewClientSecretCredential(az.AzureTenantID, az.AzureClientID, string(az.ServicePrincipalSecret), nil)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		scopeMapsClient, err := armcontainerregistry.NewScopeMapsClient(az.AzureSubscriptionID, cred, nil)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		smRes, err := scopeMapsClient.Get(
+			context.Background(),
+			az.ACRResourceGroupName,
+			az.ACRName,
+			"_repositories_admin",
+			nil,
+		)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		tokensClient, err := armcontainerregistry.NewTokensClient(az.AzureSubscriptionID, cred, nil)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		pollerResp, err := tokensClient.BeginCreate(
+			context.Background(),
+			az.ACRResourceGroupName,
+			az.ACRName,
+			"porter-acr-token",
+			armcontainerregistry.Token{
+				Properties: &armcontainerregistry.TokenProperties{
+					ScopeMapID: smRes.ID,
+					Status:     to.Ptr(armcontainerregistry.TokenStatusEnabled),
+				},
+			},
+			nil,
+		)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		tokResp, err := pollerResp.PollUntilDone(context.Background(), 2*time.Second)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		registriesClient, err := armcontainerregistry.NewRegistriesClient(az.AzureSubscriptionID, cred, nil)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		poller, err := registriesClient.BeginGenerateCredentials(
+			context.Background(),
+			az.ACRResourceGroupName,
+			az.ACRName,
+			armcontainerregistry.GenerateCredentialsParameters{
+				TokenID: tokResp.ID,
+			},
+			&armcontainerregistry.RegistriesClientBeginGenerateCredentialsOptions{ResumeToken: ""})
+
+		if err != nil {
+			return "", "", err
+		}
+
+		genCredentialsResp, err := poller.PollUntilDone(context.Background(), 2*time.Second)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		for i, tokPassword := range genCredentialsResp.Passwords {
+			if i == 0 {
+				az.ACRPassword1 = []byte(*tokPassword.Value)
+			} else if i == 1 {
+				az.ACRPassword2 = []byte(*tokPassword.Value)
+			}
+		}
+
+		// update the az integration
+		az, err = repo.AzureIntegration().OverwriteAzureIntegration(
+			az,
+		)
+
+		if err != nil {
+			return "", "", err
+		}
+	}
+
+	return az.ACRTokenName, string(az.ACRPassword1), nil
+}
+
 func (r *Registry) listDOCRRepositories(
 	repo repository.Repository,
 	doAuth *oauth2.Config,
@@ -468,6 +645,10 @@ func (r *Registry) ListImages(
 		return r.listECRImages(repoName, repo)
 	}
 
+	if r.AzureIntegrationID != 0 {
+		return r.listACRImages(repoName, repo)
+	}
+
 	if r.GCPIntegrationID != 0 {
 		return r.listGCRImages(repoName, repo)
 	}
@@ -552,6 +733,55 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 	return res, nil
 }
 
+func (r *Registry) listACRImages(repoName string, repo repository.Repository) ([]*ptypes.Image, error) {
+	az, err := repo.AzureIntegration().ReadAzureIntegration(
+		r.ProjectID,
+		r.AzureIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// use JWT token to request catalog
+	client := &http.Client{}
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/v2/%s/tags/list", r.URL, repoName),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req.SetBasicAuth(az.AzureClientID, string(az.ServicePrincipalSecret))
+
+	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 GCR repositories: %v", err)
+	}
+
+	res := make([]*ptypes.Image, 0)
+
+	for _, tag := range gcrResp.Tags {
+		res = append(res, &ptypes.Image{
+			RepositoryName: strings.TrimPrefix(repoName, "https://"),
+			Tag:            tag,
+		})
+	}
+
+	return res, nil
+}
+
 type gcrImageResp struct {
 	Tags []string `json:"tags"`
 }
@@ -845,6 +1075,10 @@ func (r *Registry) GetDockerConfigJSON(
 		conf, err = r.getPrivateRegistryDockerConfigFile(repo)
 	}
 
+	if r.AzureIntegrationID != 0 {
+		conf, err = r.getACRDockerConfigFile(repo)
+	}
+
 	if err != nil {
 		return nil, err
 	}
@@ -1015,6 +1249,34 @@ func (r *Registry) getPrivateRegistryDockerConfigFile(
 	}, nil
 }
 
+func (r *Registry) getACRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	username, pw, err := r.GetACRCredentials(repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			parsedURL.Host: {
+				Username: string(username),
+				Password: string(pw),
+				Auth:     generateAuthToken(string(username), string(pw)),
+			},
+		},
+	}, nil
+}
+
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
 }

+ 12 - 0
internal/repository/credentials/credentials.go

@@ -39,6 +39,15 @@ type AWSCredential struct {
 	AWSRegion []byte `json:"aws_region"`
 }
 
+type AzureCredential struct {
+	// The Azure service principal key
+	ServicePrincipalSecret []byte `json:"service_principal_secret"`
+
+	// The ACR passwords, if set
+	ACRPassword1 []byte `json:"acr_password_1"`
+	ACRPassword2 []byte `json:"acr_password_2"`
+}
+
 type CredentialStorage interface {
 	WriteOAuthCredential(oauthIntegration *integrations.OAuthIntegration, data *OAuthCredential) error
 	GetOAuthCredential(oauthIntegration *integrations.OAuthIntegration) (*OAuthCredential, error)
@@ -49,4 +58,7 @@ type CredentialStorage interface {
 	WriteAWSCredential(awsIntegration *integrations.AWSIntegration, data *AWSCredential) error
 	GetAWSCredential(awsIntegration *integrations.AWSIntegration) (*AWSCredential, error)
 	CreateAWSToken(awsIntegration *integrations.AWSIntegration) (string, error)
+	WriteAzureCredential(azIntegration *integrations.AzureIntegration, data *AzureCredential) error
+	GetAzureCredential(azIntegration *integrations.AzureIntegration) (*AzureCredential, error)
+	CreateAzureToken(azIntegration *integrations.AzureIntegration) (string, error)
 }

+ 230 - 0
internal/repository/gorm/auth.go

@@ -1315,3 +1315,233 @@ func (repo *GithubAppOAuthIntegrationRepository) UpdateGithubAppOauthIntegration
 
 	return am, nil
 }
+
+// AzureIntegrationRepository uses gorm.DB for querying the database
+type AzureIntegrationRepository struct {
+	db             *gorm.DB
+	key            *[32]byte
+	storageBackend credentials.CredentialStorage
+}
+
+// NewAzureIntegrationRepository returns a AzureIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewAzureIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+	storageBackend credentials.CredentialStorage,
+) repository.AzureIntegrationRepository {
+	return &AzureIntegrationRepository{db, key, storageBackend}
+}
+
+// CreateAzureIntegration creates a new Azure auth mechanism
+func (repo *AzureIntegrationRepository) CreateAzureIntegration(
+	az *ints.AzureIntegration,
+) (*ints.AzureIntegration, error) {
+	err := repo.EncryptAzureIntegrationData(az, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if storage backend is not nil, strip out credential data, which will be stored in credential
+	// storage backend after write to DB
+	var credentialData = &credentials.AzureCredential{}
+
+	if repo.storageBackend != nil {
+		credentialData.ServicePrincipalSecret = az.ServicePrincipalSecret
+		credentialData.ACRPassword1 = az.ACRPassword1
+		credentialData.ACRPassword2 = az.ACRPassword2
+		az.ServicePrincipalSecret = []byte{}
+		az.ACRPassword1 = []byte{}
+		az.ACRPassword2 = []byte{}
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", az.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("AzureIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(az); err != nil {
+		return nil, err
+	}
+
+	if repo.storageBackend != nil {
+		err = repo.storageBackend.WriteAzureCredential(az, credentialData)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return az, nil
+}
+
+// OverwriteAzureIntegration overwrites the Azure credential in the DB
+func (repo *AzureIntegrationRepository) OverwriteAzureIntegration(
+	az *ints.AzureIntegration,
+) (*ints.AzureIntegration, error) {
+	err := repo.EncryptAzureIntegrationData(az, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if storage backend is not nil, strip out credential data, which will be stored in credential
+	// storage backend after write to DB
+	var credentialData = &credentials.AzureCredential{}
+
+	if repo.storageBackend != nil {
+		credentialData.ServicePrincipalSecret = az.ServicePrincipalSecret
+		credentialData.ACRPassword1 = az.ACRPassword1
+		credentialData.ACRPassword2 = az.ACRPassword2
+		az.ServicePrincipalSecret = []byte{}
+		az.ACRPassword1 = []byte{}
+		az.ACRPassword2 = []byte{}
+	}
+
+	if err := repo.db.Save(az).Error; err != nil {
+		return nil, err
+	}
+
+	if repo.storageBackend != nil {
+		err = repo.storageBackend.WriteAzureCredential(az, credentialData)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// perform another read
+	return repo.ReadAzureIntegration(az.ProjectID, az.ID)
+}
+
+// ReadAzureIntegration finds a Azure auth mechanism by id
+func (repo *AzureIntegrationRepository) ReadAzureIntegration(
+	projectID, id uint,
+) (*ints.AzureIntegration, error) {
+	az := &ints.AzureIntegration{}
+
+	if err := repo.db.Where("project_id = ? AND id = ?", projectID, id).First(&az).Error; err != nil {
+		return nil, err
+	}
+
+	if repo.storageBackend != nil {
+		credentialData, err := repo.storageBackend.GetAzureCredential(az)
+
+		if err != nil {
+			return nil, err
+		}
+
+		az.ServicePrincipalSecret = credentialData.ServicePrincipalSecret
+		az.ACRPassword1 = credentialData.ACRPassword1
+		az.ACRPassword2 = credentialData.ACRPassword2
+	}
+
+	err := repo.DecryptAzureIntegrationData(az, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return az, nil
+}
+
+// ListAzureIntegrationsByProjectID finds all Azure auth mechanisms
+// for a given project id
+func (repo *AzureIntegrationRepository) ListAzureIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.AzureIntegration, error) {
+	azs := []*ints.AzureIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&azs).Error; err != nil {
+		return nil, err
+	}
+
+	return azs, nil
+}
+
+// EncryptAWSIntegrationData will encrypt the aws integration data before
+// writing to the DB
+func (repo *AzureIntegrationRepository) EncryptAzureIntegrationData(
+	az *ints.AzureIntegration,
+	key *[32]byte,
+) error {
+	if len(az.ServicePrincipalSecret) > 0 {
+		cipherData, err := encryption.Encrypt(az.ServicePrincipalSecret, key)
+
+		if err != nil {
+			return err
+		}
+
+		az.ServicePrincipalSecret = cipherData
+	}
+
+	if len(az.ACRPassword1) > 0 {
+		cipherData, err := encryption.Encrypt(az.ACRPassword1, key)
+
+		if err != nil {
+			return err
+		}
+
+		az.ACRPassword1 = cipherData
+	}
+
+	if len(az.ACRPassword2) > 0 {
+		cipherData, err := encryption.Encrypt(az.ACRPassword2, key)
+
+		if err != nil {
+			return err
+		}
+
+		az.ACRPassword2 = cipherData
+	}
+
+	return nil
+}
+
+// DecryptAzureIntegrationData will decrypt the Azure integration data before
+// returning it from the DB
+func (repo *AzureIntegrationRepository) DecryptAzureIntegrationData(
+	az *ints.AzureIntegration,
+	key *[32]byte,
+) error {
+	if len(az.ServicePrincipalSecret) > 0 {
+		plaintext, err := encryption.Decrypt(az.ServicePrincipalSecret, key)
+
+		if err != nil {
+			return err
+		}
+
+		az.ServicePrincipalSecret = plaintext
+	}
+
+	if len(az.ACRPassword1) > 0 {
+		plaintext, err := encryption.Decrypt(az.ACRPassword1, key)
+
+		if err != nil {
+			return err
+		}
+
+		az.ACRPassword1 = plaintext
+	}
+
+	if len(az.ACRPassword2) > 0 {
+		plaintext, err := encryption.Decrypt(az.ACRPassword2, key)
+
+		if err != nil {
+			return err
+		}
+
+		az.ACRPassword2 = plaintext
+	}
+
+	return nil
+}

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

@@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&ints.OAuthIntegration{},
 		&ints.GCPIntegration{},
 		&ints.AWSIntegration{},
+		&ints.AzureIntegration{},
 		&ints.TokenCache{},
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},

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

@@ -29,6 +29,7 @@ type GormRepository struct {
 	oauthIntegration          repository.OAuthIntegrationRepository
 	gcpIntegration            repository.GCPIntegrationRepository
 	awsIntegration            repository.AWSIntegrationRepository
+	azIntegration             repository.AzureIntegrationRepository
 	githubAppInstallation     repository.GithubAppInstallationRepository
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
@@ -132,6 +133,10 @@ func (t *GormRepository) AWSIntegration() repository.AWSIntegrationRepository {
 	return t.awsIntegration
 }
 
+func (t *GormRepository) AzureIntegration() repository.AzureIntegrationRepository {
+	return t.azIntegration
+}
+
 func (t *GormRepository) GithubAppInstallation() repository.GithubAppInstallationRepository {
 	return t.githubAppInstallation
 }
@@ -210,6 +215,7 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		oauthIntegration:          NewOAuthIntegrationRepository(db, key, storageBackend),
 		gcpIntegration:            NewGCPIntegrationRepository(db, key, storageBackend),
 		awsIntegration:            NewAWSIntegrationRepository(db, key, storageBackend),
+		azIntegration:             NewAzureIntegrationRepository(db, key, storageBackend),
 		githubAppInstallation:     NewGithubAppInstallationRepository(db),
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		slackIntegration:          NewSlackIntegrationRepository(db, key),

+ 9 - 0
internal/repository/integrations.go

@@ -61,6 +61,15 @@ type AWSIntegrationRepository interface {
 	ListAWSIntegrationsByProjectID(projectID uint) ([]*ints.AWSIntegration, error)
 }
 
+// AzureIntegrationRepository represents the set of queries on the AWS auth
+// mechanism
+type AzureIntegrationRepository interface {
+	CreateAzureIntegration(az *ints.AzureIntegration) (*ints.AzureIntegration, error)
+	OverwriteAzureIntegration(az *ints.AzureIntegration) (*ints.AzureIntegration, error)
+	ReadAzureIntegration(projectID, id uint) (*ints.AzureIntegration, error)
+	ListAzureIntegrationsByProjectID(projectID uint) ([]*ints.AzureIntegration, error)
+}
+
 // GCPIntegrationRepository represents the set of queries on the GCP auth
 // mechanism
 type GCPIntegrationRepository interface {

+ 1 - 0
internal/repository/repository.go

@@ -23,6 +23,7 @@ type Repository interface {
 	OAuthIntegration() OAuthIntegrationRepository
 	GCPIntegration() GCPIntegrationRepository
 	AWSIntegration() AWSIntegrationRepository
+	AzureIntegration() AzureIntegrationRepository
 	GithubAppInstallation() GithubAppInstallationRepository
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository

+ 40 - 0
internal/repository/test/auth.go

@@ -566,3 +566,43 @@ func (repo *GithubAppOAuthIntegrationRepository) UpdateGithubAppOauthIntegration
 
 	return am, nil
 }
+
+// AzureIntegrationRepository (unimplemented)
+type AzureIntegrationRepository struct {
+}
+
+// NewAzureIntegrationRepository returns a AzureIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewAzureIntegrationRepository() repository.AzureIntegrationRepository {
+	return &AzureIntegrationRepository{}
+}
+
+// CreateAzureIntegration creates a new Azure auth mechanism
+func (repo *AzureIntegrationRepository) CreateAzureIntegration(
+	az *ints.AzureIntegration,
+) (*ints.AzureIntegration, error) {
+	panic("unimplemented")
+}
+
+// OverwriteAzureIntegration overwrites the Azure credential in the DB
+func (repo *AzureIntegrationRepository) OverwriteAzureIntegration(
+	az *ints.AzureIntegration,
+) (*ints.AzureIntegration, error) {
+	panic("unimplemented")
+}
+
+// ReadAzureIntegration finds a Azure auth mechanism by id
+func (repo *AzureIntegrationRepository) ReadAzureIntegration(
+	projectID, id uint,
+) (*ints.AzureIntegration, error) {
+	panic("unimplemented")
+}
+
+// ListAzureIntegrationsByProjectID finds all Azure auth mechanisms
+// for a given project id
+func (repo *AzureIntegrationRepository) ListAzureIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.AzureIntegration, error) {
+	panic("unimplemented")
+}

+ 6 - 0
internal/repository/test/repository.go

@@ -26,6 +26,7 @@ type TestRepository struct {
 	oauthIntegration          repository.OAuthIntegrationRepository
 	gcpIntegration            repository.GCPIntegrationRepository
 	awsIntegration            repository.AWSIntegrationRepository
+	azIntegration             repository.AzureIntegrationRepository
 	githubAppInstallation     repository.GithubAppInstallationRepository
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
@@ -126,6 +127,10 @@ func (t *TestRepository) AWSIntegration() repository.AWSIntegrationRepository {
 	return t.awsIntegration
 }
 
+func (t *TestRepository) AzureIntegration() repository.AzureIntegrationRepository {
+	return t.azIntegration
+}
+
 func (t *TestRepository) GithubAppInstallation() repository.GithubAppInstallationRepository {
 	return t.githubAppInstallation
 }
@@ -207,6 +212,7 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		oauthIntegration:          NewOAuthIntegrationRepository(canQuery),
 		gcpIntegration:            NewGCPIntegrationRepository(canQuery),
 		awsIntegration:            NewAWSIntegrationRepository(canQuery),
+		azIntegration:             NewAzureIntegrationRepository(),
 		githubAppInstallation:     NewGithubAppInstallationRepository(canQuery),
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),

+ 1 - 1
services/cli_install_script_container/Dockerfile

@@ -1,4 +1,4 @@
-FROM golang:1.17.6-alpine3.14
+FROM golang:1.18-alpine
 
 WORKDIR /app
 COPY . .

+ 1 - 1
services/porter_cli_container/dev.Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.17 as base
+FROM golang:1.18 as base
 WORKDIR /porter
 
 RUN apt-get update && apt-get install -y gcc musl-dev git

部分文件因文件數量過多而無法顯示