ソースを参照

Merge branch 'nafees/api-v1' into dev

Mohammed Nafees 3 年 前
コミット
7b1e7d0afe

+ 84 - 60
.github/workflows/dev.yaml

@@ -22,7 +22,7 @@ jobs:
       - name: Install kubectl
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         uses: azure/setup-kubectl@v2.0
         with:
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout
@@ -50,7 +50,7 @@ jobs:
       - name: Deploy to cluster
       - name: Deploy to cluster
         run: |
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
-            
+
           kubectl rollout restart deployment/porter
           kubectl rollout restart deployment/porter
   deploy-provisioner:
   deploy-provisioner:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -70,7 +70,7 @@ jobs:
       - name: Install kubectl
       - name: Install kubectl
         uses: azure/setup-kubectl@v2.0
         uses: azure/setup-kubectl@v2.0
         with:
         with:
-          version: 'v1.19.15'
+          version: "v1.19.15"
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout
@@ -84,67 +84,91 @@ jobs:
       - name: Deploy to cluster
       - name: Deploy to cluster
         run: |
         run: |
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
           aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
-            
+
           kubectl rollout restart deployment/provisioner
           kubectl rollout restart deployment/provisioner
   build-push-ecr-server:
   build-push-ecr-server:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-    - name: Checkout code
-      uses: actions/checkout@v2.3.4
-    - name: Set Github tag
-      id: vars
-      run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
-    - name: Configure AWS credentials
-      uses: aws-actions/configure-aws-credentials@v1
-      with:
-        aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
-        aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
-        aws-region: us-east-2
-    - name: Login to ECR
-      id: login-ecr
-      run: |
-        aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
-    - name: Write Dashboard Environment Variables
-      run: |
-        cat >./dashboard/.env <<EOL
-        NODE_ENV=development
-        API_SERVER=dashboard.dev.getporter.dev
-        DISCORD_KEY=${{secrets.DISCORD_KEY}}
-        DISCORD_CID=${{secrets.DISCORD_CID}}
-        FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-        APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
-        ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
-        ENABLE_SENTRY=true
-        SENTRY_DSN=${{secrets.SENTRY_DSN}}
-        SENTRY_ENV=frontend-development
-        EOL
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/ee.Dockerfile
-    - name: Push to ECR
-      run: |
-        docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }}
+      - name: Checkout code
+        uses: actions/checkout@v2.3.4
+      - name: Set Github tag
+        id: vars
+        run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR
+        id: login-ecr
+        run: |
+          aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
+      - name: Write Dashboard Environment Variables
+        run: |
+          cat >./dashboard/.env <<EOL
+          NODE_ENV=development
+          API_SERVER=dashboard.dev.getporter.dev
+          DISCORD_KEY=${{secrets.DISCORD_KEY}}
+          DISCORD_CID=${{secrets.DISCORD_CID}}
+          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
+          ENABLE_SENTRY=true
+          SENTRY_DSN=${{secrets.SENTRY_DSN}}
+          SENTRY_ENV=frontend-development
+          EOL
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/ee.Dockerfile
+      - name: Push to ECR
+        run: |
+          docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/porter:${{ steps.vars.outputs.sha_short }}
   build-push-ecr-provisioner:
   build-push-ecr-provisioner:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-    - name: Checkout code
-      uses: actions/checkout@v2.3.4
-    - name: Set Github tag
-      id: vars
-      run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
-    - name: Configure AWS credentials
-      uses: aws-actions/configure-aws-credentials@v1
-      with:
-        aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
-        aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
-        aws-region: us-east-2
-    - name: Login to ECR
-      id: login-ecr
-      run: |
-        aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
-    - name: Build
-      run: |
-        DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/provisioner.Dockerfile
-    - name: Push to ECR
-      run: |
-        docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }}
+      - name: Checkout code
+        uses: actions/checkout@v2.3.4
+      - name: Set Github tag
+        id: vars
+        run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR
+        id: login-ecr
+        run: |
+          aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }} -f ./ee/docker/provisioner.Dockerfile
+      - name: Push to ECR
+        run: |
+          docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/provisioner-service:${{ steps.vars.outputs.sha_short }}
+  build-push-worker-pool:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2.3.4
+      - name: Set Github tag
+        id: vars
+        run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_DEV_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_DEV_AWS_ACCESS_SECRET_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR
+        id: login-ecr
+        run: |
+          aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 801172602658.dkr.ecr.us-east-2.amazonaws.com
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }} -f ./workers/Dockerfile
+      - name: Push to ECR
+        run: |
+          docker push 801172602658.dkr.ecr.us-east-2.amazonaws.com/worker-pool:${{ steps.vars.outputs.sha_short }}

+ 28 - 0
.github/workflows/prerelease.yaml

@@ -78,6 +78,34 @@ jobs:
       - name: Push to ECR public
       - name: Push to ECR public
         run: |
         run: |
           docker push public.ecr.aws/o1j4x7p4/provisioner-service:${{steps.tag_name.outputs.tag}}
           docker push public.ecr.aws/o1j4x7p4/provisioner-service:${{steps.tag_name.outputs.tag}}
+  build-push-worker-pool:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . -t public.ecr.aws/o1j4x7p4/worker-pool:${{steps.tag_name.outputs.tag}} -f ./workers/Dockerfile
+      - name: Push to ECR public
+        run: |
+          docker push public.ecr.aws/o1j4x7p4/worker-pool:${{steps.tag_name.outputs.tag}}
   build-linux:
   build-linux:
     name: Build Linux binaries
     name: Build Linux binaries
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest

+ 183 - 0
api/server/handlers/stack/add_application.go

@@ -0,0 +1,183 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+	helmrelease "helm.sh/helm/v3/pkg/release"
+)
+
+type StackAddApplicationHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddApplicationHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddApplicationHandler {
+	return &StackAddApplicationHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackAppResourceRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newResources, err := getResourceModels([]*types.CreateStackAppResourceRequest{req}, newSourceConfigs, p.Config().ServerConf.DefaultApplicationHelmRepoURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources = append(appResources, newResources...)
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmReleaseMap := make(map[string]*helmrelease.Release)
+
+	deployErrs := make([]string, 0)
+
+	for _, appResource := range newResources {
+		rel, err := applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    req,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		} else {
+			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+		}
+	}
+
+	// update stack revision status
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	saveErrs := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+			if err != nil {
+				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+			}
+		}
+	}
+
+	if len(saveErrs) > 0 {
+		revision.Reason = "SaveError"
+		revision.Message = strings.Join(saveErrs, " , ")
+
+		_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 150 - 0
api/server/handlers/stack/add_env_group.go

@@ -0,0 +1,150 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackAddEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddEnvGroupHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddEnvGroupHandler {
+	return &StackAddEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackEnvGroupRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newEnvGroups, err := getEnvGroupModels([]*types.CreateStackEnvGroupRequest{req}, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups = append(envGroups, newEnvGroups...)
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeployed),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroupDeployErrors := make([]string, 0)
+
+	cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+		Name:            req.Name,
+		Namespace:       namespace,
+		Variables:       req.Variables,
+		SecretVariables: req.SecretVariables,
+	})
+
+	if err != nil {
+		envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+	}
+
+	// add each of the linked applications to the env group
+	for _, appName := range req.LinkedApplications {
+		cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
+
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+		}
+	}
+
+	if len(envGroupDeployErrors) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 130 - 0
api/server/handlers/stack/remove_application.go

@@ -0,0 +1,130 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveApplicationHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveApplicationHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveApplicationHandler {
+	return &StackRemoveApplicationHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	appResourceName, reqErr := requestutils.GetURLParamString(r, "app_resource_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newResources []models.StackResource
+
+	for _, res := range appResources {
+		if res.Name != appResourceName {
+			newResources = append(newResources, res)
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      newResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = deleteAppResource(&deleteAppResourceOpts{
+		helmAgent: helmAgent,
+		name:      appResourceName,
+	})
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveAppError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 130 - 0
api/server/handlers/stack/remove_env_group.go

@@ -0,0 +1,130 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveEnvGroupHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveEnvGroupHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveEnvGroupHandler {
+	return &StackRemoveEnvGroupHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envGroupName, reqErr := requestutils.GetURLParamString(r, "env_group_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newEnvGroups []models.StackEnvGroup
+	var envGroupNS string
+
+	for _, envGroup := range envGroups {
+		if envGroup.Name != envGroupName {
+			newEnvGroups = append(newEnvGroups, envGroup)
+		} else {
+			envGroupNS = envGroup.Namespace
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      newEnvGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = envgroup.DeleteEnvGroup(k8sAgent, envGroupName, envGroupNS)
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveEnvGroupError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 283 - 1
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup
 type stackPathParams struct {
 type stackPathParams struct {
 	// The project id
 	// The project id
 	// in: path
 	// in: path
@@ -65,6 +65,66 @@ type stackRevisionPathParams struct {
 	RevisionID string `json:"revision_id"`
 	RevisionID string `json:"revision_id"`
 }
 }
 
 
+// swagger:parameters removeApplication
+type stackRemoveApplicationPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the application
+	// in: path
+	// required: true
+	AppResourceName string `json:"app_resource_name"`
+}
+
+// swagger:parameters removeEnvGroup
+type stackRemoveEnvGroupPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the environment group
+	// in: path
+	// required: true
+	EnvGroupName string `json:"env_group_name"`
+}
+
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 	return &router.Registerer{
 		GetRoutes: GetV1StackScopedRoutes,
 		GetRoutes: GetV1StackScopedRoutes,
@@ -538,5 +598,227 @@ func getV1StackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application -> stack.NewStackAddApplicationHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application addApplication
+	//
+	// Adds an application to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an application to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddApplicationToStack
+	//     description: The application to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackAppResourceRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the application to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_application",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addApplicationHandler := stack.NewStackAddApplicationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addApplicationEndpoint,
+		Handler:  addApplicationHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} -> stack.NewStackRemoveApplicationHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} removeApplication
+	//
+	// Removes an existing application from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an application from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: app_resource_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the application from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_application/{app_resource_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeApplicationHandler := stack.NewStackRemoveApplicationHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeApplicationEndpoint,
+		Handler:  removeApplicationHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group -> stack.NewStackAddEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group addEnvGroup
+	//
+	// Adds an environment group to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an environment group to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddEnvGroupToStack
+	//     description: The environment group to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackEnvGroupRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the environment group to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_env_group",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addEnvGroupHandler := stack.NewStackAddEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addEnvGroupEndpoint,
+		Handler:  addEnvGroupHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} -> stack.NewStackRemoveEnvGroupHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} removeEnvGroup
+	//
+	// Removes an existing environment group from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an environment group from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: env_group_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the environment group from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_env_group/{env_group_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeEnvGroupHandler := stack.NewStackRemoveEnvGroupHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeEnvGroupEndpoint,
+		Handler:  removeEnvGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 1 - 1
api/server/shared/commonutils/git_utils.go

@@ -39,7 +39,7 @@ func GetLatestWorkflowRun(client *github.Client, owner, repo, filename, branch s
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	if workflowRuns == nil || workflowRuns.GetTotalCount() == 0 {
+	if workflowRuns == nil || workflowRuns.GetTotalCount() == 0 || len(workflowRuns.WorkflowRuns) == 0 {
 		return nil, ErrNoWorkflowRuns
 		return nil, ErrNoWorkflowRuns
 	}
 	}
 
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -118,7 +118,7 @@ const DeploymentList = () => {
         }
         }
 
 
         setDeploymentList(deploymentList.deployments || []);
         setDeploymentList(deploymentList.deployments || []);
-        setPullRequests(deploymentList.pull_requrests || []);
+        setPullRequests(deploymentList.pull_requests || []);
 
 
         setNewCommentsDisabled(environmentList.new_comments_disabled || false);
         setNewCommentsDisabled(environmentList.new_comments_disabled || false);
 
 

+ 1 - 0
go.mod

@@ -112,6 +112,7 @@ require (
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/xanzy/go-gitlab v0.68.0 // indirect
 	github.com/xanzy/go-gitlab v0.68.0 // indirect
+	go.uber.org/goleak v1.1.12 // indirect
 )
 )
 
 
 require (
 require (

+ 1 - 0
go.sum

@@ -2099,6 +2099,7 @@ go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
 go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=

+ 10 - 0
internal/helm/agent.go

@@ -141,6 +141,16 @@ func (a *Agent) GetRelease(
 	return release, err
 	return release, err
 }
 }
 
 
+// DeleteReleaseRevision deletes a specific revision of a release
+func (a *Agent) DeleteReleaseRevision(
+	name string,
+	version int,
+) error {
+	_, err := a.ActionConfig.Releases.Delete(name, version)
+
+	return err
+}
+
 // GetReleaseHistory returns a list of charts for a specific release
 // GetReleaseHistory returns a list of charts for a specific release
 func (a *Agent) GetReleaseHistory(
 func (a *Agent) GetReleaseHistory(
 	name string,
 	name string,

+ 3 - 0
internal/models/cluster.go

@@ -75,6 +75,9 @@ type Cluster struct {
 
 
 	// CertificateAuthorityData for the cluster, encrypted at rest
 	// CertificateAuthorityData for the cluster, encrypted at rest
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
+
+	// MonitorHelmReleases to trim down the number of revisions per release
+	MonitorHelmReleases bool
 }
 }
 
 
 // ToProjectType generates an external types.Project to be shared over REST
 // ToProjectType generates an external types.Project to be shared over REST

+ 76 - 0
internal/worker/dispatcher.go

@@ -0,0 +1,76 @@
+package worker
+
+import (
+	"log"
+
+	"github.com/google/uuid"
+)
+
+// Dispatcher is responsible to maintain a global worker pool
+// and to dispatch jobs to the underlying workers, in random order
+type Dispatcher struct {
+	maxWorkers int
+	exitChan   chan bool
+
+	WorkerPool chan chan Job
+}
+
+// NewDispatcher creates a new instance of Dispatcher with
+// the given number of workers that should be in the worker pool
+func NewDispatcher(maxWorkers int) *Dispatcher {
+	pool := make(chan chan Job, maxWorkers)
+	return &Dispatcher{
+		maxWorkers: maxWorkers,
+		exitChan:   make(chan bool),
+
+		WorkerPool: pool,
+	}
+}
+
+// Run creates workers in the worker pool with the given
+// job queue and starts the workers
+func (d *Dispatcher) Run(jobQueue chan Job) error {
+	go func() {
+		var workers []*Worker
+
+		for i := 0; i < d.maxWorkers; i += 1 {
+			uuid, err := uuid.NewUUID()
+
+			if err != nil {
+				// FIXME: should let the parent thread know of this error
+				log.Printf("error creating UUID for worker: %v", err)
+				return
+			}
+
+			worker := NewWorker(uuid, d.WorkerPool)
+			workers = append(workers, worker)
+
+			log.Printf("starting worker with UUID: %v", uuid)
+
+			worker.Start()
+		}
+
+		for {
+			select {
+			case job := <-jobQueue:
+				go func() {
+					workerJobChan := <-d.WorkerPool
+					workerJobChan <- job
+				}()
+			case <-d.exitChan:
+				for _, w := range workers {
+					w.Stop()
+				}
+
+				return
+			}
+		}
+	}()
+
+	return nil
+}
+
+// Exit instructs the dispatcher to quit processing any more jobs
+func (d *Dispatcher) Exit() {
+	d.exitChan <- true
+}

+ 22 - 0
internal/worker/dispatcher_test.go

@@ -0,0 +1,22 @@
+package worker
+
+import (
+	"testing"
+
+	"go.uber.org/goleak"
+)
+
+func TestDispatcher(t *testing.T) {
+	defer goleak.VerifyNone(t)
+
+	jobChan := make(chan Job)
+
+	d := NewDispatcher(10)
+	err := d.Run(jobChan)
+
+	if err != nil {
+		panic(err)
+	}
+
+	d.Exit()
+}

+ 73 - 0
internal/worker/worker.go

@@ -0,0 +1,73 @@
+package worker
+
+import (
+	"log"
+	"time"
+
+	"github.com/google/uuid"
+)
+
+// Job is an interface which should be implemented by an individual
+// worker process in order to be enqueued in the worker pool
+type Job interface {
+	// The unique string ID of a job
+	ID() string
+
+	// The time in UTC when a job was enqueued to the worker pool queue
+	EnqueueTime() time.Time
+
+	// The main logic and control of a job
+	Run() error
+
+	// To set external data if a job needs it
+	SetData([]byte)
+}
+
+// Worker handles a single job or worker process
+type Worker struct {
+	exitChan chan bool
+	uuid     uuid.UUID
+
+	WorkerPool chan chan Job
+	JobChannel chan Job
+}
+
+// NewWorker creates a new instance of Worker with the given
+// RFC 4122 UUID and a global worker pool
+func NewWorker(uuid uuid.UUID, workerPool chan chan Job) *Worker {
+	return &Worker{
+		exitChan: make(chan bool),
+		uuid:     uuid,
+
+		WorkerPool: workerPool,
+		JobChannel: make(chan Job),
+	}
+}
+
+// Start spawns a goroutine to add itself to the global worker pool
+// and listens for incoming jobs as they come, in random order
+func (w *Worker) Start() {
+	go func() {
+		for {
+			w.WorkerPool <- w.JobChannel
+
+			select {
+			case job := <-w.JobChannel:
+				log.Printf("attempting to run job ID '%s' via worker '%s'", job.ID(), w.uuid.String())
+
+				if err := job.Run(); err != nil {
+					log.Printf("error running job %s: %s", job.ID(), err.Error())
+				}
+			case <-w.exitChan:
+				log.Printf("quitting worker with UUID: %v", w.uuid)
+
+				return
+			}
+		}
+	}()
+}
+
+// Stop instructs the worker to stop listening for incoming jobs
+func (w *Worker) Stop() {
+	w.exitChan <- true
+}

+ 25 - 0
internal/worker/worker_test.go

@@ -0,0 +1,25 @@
+package worker
+
+import (
+	"testing"
+
+	"github.com/google/uuid"
+	"go.uber.org/goleak"
+)
+
+func TestWorker(t *testing.T) {
+	defer goleak.VerifyNone(t)
+
+	uuid, err := uuid.NewUUID()
+
+	if err != nil {
+		panic(err)
+	}
+
+	workerPool := make(chan chan Job, 10)
+
+	w := NewWorker(uuid, workerPool)
+
+	w.Start()
+	w.Stop()
+}

+ 20 - 0
provisioner/integrations/storage/s3/s3.go

@@ -78,6 +78,26 @@ func (s *S3StorageClient) WriteFile(infra *models.Infra, name string, fileBytes
 	return err
 	return err
 }
 }
 
 
+func (s *S3StorageClient) WriteFileWithKey(fileBytes []byte, shouldEncrypt bool, key string) error {
+	body := fileBytes
+	var err error
+	if shouldEncrypt {
+		body, err = encryption.Encrypt(fileBytes, s.encryptionKey)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	_, err = s.client.PutObject(&s3.PutObjectInput{
+		Body:   aws.ReadSeekCloser(bytes.NewReader(body)),
+		Bucket: &s.bucket,
+		Key:    aws.String(key),
+	})
+
+	return err
+}
+
 func (s *S3StorageClient) ReadFile(infra *models.Infra, name string, shouldDecrypt bool) ([]byte, error) {
 func (s *S3StorageClient) ReadFile(infra *models.Infra, name string, shouldDecrypt bool) ([]byte, error) {
 	output, err := s.client.GetObject(&s3.GetObjectInput{
 	output, err := s.client.GetObject(&s3.GetObjectInput{
 		Bucket: &s.bucket,
 		Bucket: &s.bucket,

+ 30 - 0
workers/Dockerfile

@@ -0,0 +1,30 @@
+# This Dockerfile is used for building the worker pool binary itself
+
+# Buildtime environment
+# -------------------------------------------
+FROM golang:1.18-alpine3.16 as build
+WORKDIR /app
+
+RUN apk update && apk add gcc binutils-gold musl-dev
+
+COPY go.mod .
+COPY go.sum .
+COPY /api ./api
+COPY /ee ./ee
+COPY /internal ./internal
+COPY /pkg ./pkg
+COPY /provisioner ./provisioner
+COPY /workers ./workers
+
+RUN go build -ldflags '-w -s' -tags ee -a -o ./bin/worker-pool ./workers
+
+# Runtime environment
+# ----------------------
+FROM alpine:3.16
+WORKDIR /app
+
+RUN apk update && apk add curl
+
+COPY --from=build /app/bin/worker-pool /usr/bin/
+
+ENTRYPOINT [ "worker-pool" ]

+ 28 - 0
workers/doc.go

@@ -0,0 +1,28 @@
+/*
+
+                            === Porter Worker Pool and Job Queue System ===
+
+This software is intended to be deployed alongside the main Porter server and dashboard and act as a background
+worker pool for certain jobs that the Porter server should be running as separate processes / goroutines periodically
+or at-will, depending on the task at hand.
+
+TERMINOLOGIES
+
+  - The terms `worker pool`, `pool`, `Go application` are interchangably used to denote this application.
+  - Jobs should have their unique string identifiers, denoted as IDs for short.
+
+ARCHITECTURE
+
+  - The worker pool is a Go application that takes in environment variables `MAX_WORKERS` and `MAX_QUEUE` to
+    denote the maximum number of workers and maximum number of jobs in the queue, respectively.
+  - The worker pool has specific jobs that it can execute, written separately with their own logic flow.
+  - The individual jobs need to have a unique string identifier.
+  - The jobs should be registered at startup time with their respective unique identifiers for the worker pool
+    to correctly relay execution information to the correct job.
+  - The worker pool has an exposed HTTP POST endpoint to enqueue jobs with their IDs. Depending on the kind of job,
+    a job can expect to receive a body of JSON data in the HTTP request.
+  - By exposing an HTTP endpoint, the worker pool can be called to enqueue jobs using crontab and other sources.
+
+*/
+
+package main

+ 302 - 0
workers/jobs/helm_revisions_count_tracker.go

@@ -0,0 +1,302 @@
+//go:build ee
+
+/*
+
+                            === Helm Release Revisions Tracker Job ===
+
+This job keeps a track of helm releases and their revisions and deletes older revisions once they are
+backed up to an S3 bucket.
+
+  - The job looks for clusters which have the `monitor_helm_releases` set to true.
+  - The clusters are then checked for old helm release revisions.
+  - In a cluster, list of all namespaces is fetched.
+  - For every namespace, the list of releases is fetched.
+  - For every release, its revision history is fetched.
+  - If the number of revisions exceeds 100, then we intend to only keep the most recent 100 revisions.
+  - For this, the older revisions are first backed up to an S3 bucket and then deleted.
+
+*/
+
+package jobs
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"sync"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/pkg/logger"
+	"github.com/porter-dev/porter/provisioner/integrations/storage/s3"
+
+	"github.com/porter-dev/porter/ee/integrations/vault"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	rcreds "github.com/porter-dev/porter/internal/repository/credentials"
+	rgorm "github.com/porter-dev/porter/internal/repository/gorm"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+	"helm.sh/helm/v3/pkg/releaseutil"
+)
+
+var stepSize int = 100
+
+type helmRevisionsCountTracker struct {
+	enqueueTime        time.Time
+	db                 *gorm.DB
+	repo               repository.Repository
+	doConf             *oauth2.Config
+	dbConf             *env.DBConf
+	credBackend        rcreds.CredentialStorage
+	awsAccessKeyID     string
+	awsSecretAccessKey string
+	awsRegion          string
+	s3BucketName       string
+	encryptionKey      *[32]byte
+}
+
+// HelmRevisionsCountTrackerOpts holds the options required to run this job
+type HelmRevisionsCountTrackerOpts struct {
+	DBConf             *env.DBConf
+	DOClientID         string
+	DOClientSecret     string
+	DOScopes           []string
+	ServerURL          string
+	AWSAccessKeyID     string
+	AWSSecretAccessKey string
+	AWSRegion          string
+	S3BucketName       string
+	EncryptionKey      string
+}
+
+func NewHelmRevisionsCountTracker(
+	enqueueTime time.Time,
+	opts *HelmRevisionsCountTrackerOpts,
+) (*helmRevisionsCountTracker, error) {
+	db, err := adapter.New(opts.DBConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var credBackend rcreds.CredentialStorage
+
+	if opts.DBConf.VaultAPIKey != "" && opts.DBConf.VaultServerURL != "" && opts.DBConf.VaultPrefix != "" {
+		credBackend = vault.NewClient(
+			opts.DBConf.VaultServerURL,
+			opts.DBConf.VaultAPIKey,
+			opts.DBConf.VaultPrefix,
+		)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte(opts.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := rgorm.NewRepository(db, &key, credBackend)
+
+	doConf := oauth.NewDigitalOceanClient(&oauth.Config{
+		ClientID:     opts.DOClientID,
+		ClientSecret: opts.DOClientSecret,
+		Scopes:       opts.DOScopes,
+		BaseURL:      opts.ServerURL,
+	})
+
+	var s3Key [32]byte
+
+	for i, b := range []byte(opts.EncryptionKey) {
+		s3Key[i] = b
+	}
+
+	return &helmRevisionsCountTracker{
+		enqueueTime, db, repo, doConf, opts.DBConf, credBackend,
+		opts.AWSAccessKeyID, opts.AWSSecretAccessKey, opts.AWSRegion,
+		opts.S3BucketName, &s3Key,
+	}, nil
+}
+
+func (t *helmRevisionsCountTracker) ID() string {
+	return "helm-revisions-count-tracker"
+}
+
+func (t *helmRevisionsCountTracker) EnqueueTime() time.Time {
+	return t.enqueueTime
+}
+
+func (t *helmRevisionsCountTracker) Run() error {
+	var count int64
+
+	if err := t.db.Model(&models.Cluster{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		var clusters []*models.Cluster
+
+		if err := t.db.Order("id asc").Offset(i*stepSize).Limit(stepSize).Find(&clusters, "monitor_helm_releases = ?", "1").
+			Error; err != nil {
+			return err
+		}
+
+		// go through each project
+		for _, cluster := range clusters {
+			wg.Add(1)
+
+			go func(projID, clusterID uint) {
+				defer wg.Done()
+
+				log.Printf("starting release revision monitoring for cluster with ID %d", cluster.ID)
+
+				cluster, err := t.repo.Cluster().ReadCluster(projID, clusterID)
+
+				if err != nil {
+					log.Printf("error reading cluster ID %d: %v. skipping cluster ...", clusterID, err)
+					return
+				}
+
+				// create s3 client to store revisions that need to be deleted
+				s3Client, err := s3.NewS3StorageClient(&s3.S3Options{
+					t.awsRegion, t.awsAccessKeyID, t.awsSecretAccessKey, t.s3BucketName, t.encryptionKey,
+				})
+
+				if err != nil {
+					log.Printf("error creating S3 client for cluster ID %d: %v. skipping cluster ...", cluster.ID, err)
+					return
+				}
+
+				k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(&kubernetes.OutOfClusterConfig{
+					Cluster:                   cluster,
+					Repo:                      t.repo,
+					DigitalOceanOAuth:         t.doConf,
+					AllowInClusterConnections: false,
+				})
+
+				if err != nil {
+					log.Printf("error getting k8s agent for cluster ID %d: %v. skipping cluster ...", cluster.ID, err)
+					return
+				}
+
+				namespaces, err := k8sAgent.ListNamespaces()
+
+				if err != nil {
+					log.Printf("error fetching namespaces for cluster ID %d: %v. skipping cluster ...", cluster.ID, err)
+					return
+				}
+
+				log.Printf("fetched %d namespaces for cluster ID %d", len(namespaces.Items), cluster.ID)
+
+				for _, ns := range namespaces.Items {
+					agent, err := helm.GetAgentOutOfClusterConfig(&helm.Form{
+						Cluster:                   cluster,
+						Namespace:                 ns.Name,
+						Repo:                      t.repo,
+						DigitalOceanOAuth:         t.doConf,
+						AllowInClusterConnections: false,
+					}, logger.New(true, os.Stdout))
+
+					if err != nil {
+						log.Printf("error fetching helm client for namespace %s in cluster ID %d: %v. "+
+							"skipping namespace ...", ns.Name, cluster.ID, err)
+						continue
+					}
+
+					releases, err := agent.ListReleases(ns.GetName(), &types.ReleaseListFilter{
+						ByDate: true,
+						StatusFilter: []string{
+							"deployed",
+							"pending",
+							"pending-install",
+							"pending-upgrade",
+							"pending-rollback",
+							"failed",
+						},
+					})
+
+					if err != nil {
+						log.Printf("error fetching releases for namespace %s in cluster ID %d: %v. skipping namespace ...",
+							len(releases), ns.Name, cluster.ID, err)
+						continue
+					}
+
+					log.Printf("fetched %d releases for namespace %s in cluster ID %d", len(releases), ns.Name, cluster.ID)
+
+					for _, rel := range releases {
+						revisions, err := agent.GetReleaseHistory(rel.Name)
+
+						if err != nil {
+							log.Printf("error fetching release history for release %s in namespace %s of cluster ID %d: %v."+
+								" skipping release ...", rel.Name, ns.Name, cluster.ID, err)
+							continue
+						}
+
+						if len(revisions) <= 100 {
+							log.Printf("release %s of namespace %s in cluster ID %d has <= 100 revisions. "+
+								"skipping release...", rel.Name, ns.Name, cluster.ID)
+							continue
+						}
+
+						log.Printf("release %s of namespace %s in cluster ID %d has more than 100 revisions. attempting to "+
+							"delete the older ones.", rel.Name, ns.Name, cluster.ID)
+
+						// sort revisions from newest to oldest
+						releaseutil.Reverse(revisions, releaseutil.SortByRevision)
+
+						for i := 100; i < len(revisions); i += 1 {
+							rev := revisions[i]
+
+							// store the revision in the s3 bucket before deleting it
+							data, err := json.Marshal(rev)
+
+							if err != nil {
+								log.Printf("error marshalling revision for release %s, number %d: %v. skipping revision ...",
+									rev.Name, rev.Version, err)
+								continue
+							}
+
+							// write to the bucket with key - <project_id>/<cluster_id>/<namespace>/<release_name>/<revision_number>
+							err = s3Client.WriteFileWithKey(data, true, fmt.Sprintf("%d/%d/%s/%s/%d", cluster.ProjectID,
+								cluster.ID, rel.Namespace, rel.Name, rev.Version))
+
+							if err != nil {
+								log.Printf("error backing up revision for release %s, number %d: %v. skipping revision ...",
+									rev.Name, rev.Version, err)
+								continue
+							}
+
+							log.Printf("revision %d of release %s in namespace %s of cluster ID %d was successfully backed up.",
+								rev.Version, rel.Name, ns.Name, cluster.ID)
+
+							err = agent.DeleteReleaseRevision(rev.Name, rev.Version)
+
+							if err != nil {
+								log.Printf("error deleting revision %d of release %s in namespace %s of cluster ID %d: %v",
+									rev.Version, rel.Name, ns.Name, cluster.ID, err)
+								continue
+							}
+
+							log.Printf("revision %d of release %s in namespace %s of cluster ID %d was successfully deleted.",
+								rev.Version, rel.Name, ns.Name, cluster.ID)
+						}
+					}
+				}
+			}(cluster.ProjectID, cluster.ID)
+		}
+
+		wg.Wait()
+	}
+
+	return nil
+}
+
+func (t *helmRevisionsCountTracker) SetData([]byte) {}

+ 159 - 0
workers/main.go

@@ -0,0 +1,159 @@
+//go:build ee
+
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/go-chi/chi"
+	"github.com/go-chi/chi/middleware"
+	"github.com/joeshaw/envdecode"
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/internal/worker"
+	"github.com/porter-dev/porter/workers/jobs"
+)
+
+var (
+	jobQueue   chan worker.Job
+	envDecoder = EnvConf{}
+)
+
+// EnvConf holds the environment variables for this binary
+type EnvConf struct {
+	ServerURL          string `env:"SERVER_URL,default=http://localhost:8080"`
+	DOClientID         string `env:"DO_CLIENT_ID"`
+	DOClientSecret     string `env:"DO_CLIENT_SECRET"`
+	DBConf             env.DBConf
+	MaxWorkers         uint   `env:"MAX_WORKERS,default=10"`
+	MaxQueue           uint   `env:"MAX_QUEUE,default=100"`
+	AWSAccessKeyID     string `env:"AWS_ACCESS_KEY_ID"`
+	AWSSecretAccessKey string `env:"AWS_SECRET_ACCESS_KEY"`
+	AWSRegion          string `env:"AWS_REGION"`
+	S3BucketName       string `env:"S3_BUCKET_NAME"`
+	EncryptionKey      string `env:"S3_ENCRYPTION_KEY"`
+
+	Port uint `env:"PORT,default=3000"`
+}
+
+func main() {
+	if err := envdecode.StrictDecode(&envDecoder); err != nil {
+		log.Fatalf("Failed to decode server conf: %v", err)
+	}
+
+	log.Printf("setting max worker count to: %d\n", envDecoder.MaxWorkers)
+	log.Printf("setting max job queue count to: %d\n", envDecoder.MaxQueue)
+
+	jobQueue = make(chan worker.Job, envDecoder.MaxQueue)
+	d := worker.NewDispatcher(int(envDecoder.MaxWorkers))
+
+	log.Println("starting worker dispatcher")
+
+	err := d.Run(jobQueue)
+
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	server := &http.Server{Addr: fmt.Sprintf(":%d", envDecoder.Port), Handler: httpService()}
+
+	serverCtx, serverStopCtx := context.WithCancel(context.Background())
+
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
+	go func() {
+		<-sig
+
+		log.Println("shutting down server")
+
+		shutdownCtx, shutdownCtxCancel := context.WithTimeout(serverCtx, 30*time.Second)
+		defer shutdownCtxCancel()
+
+		go func() {
+			<-shutdownCtx.Done()
+			if shutdownCtx.Err() == context.DeadlineExceeded {
+				log.Fatal("graceful shutdown timed out.. forcing exit.")
+			}
+		}()
+
+		err = server.Shutdown(shutdownCtx)
+
+		if err != nil {
+			log.Fatalln(err)
+		}
+
+		log.Println("server shutdown completed")
+
+		serverStopCtx()
+	}()
+
+	log.Println("starting HTTP server at :3000")
+
+	err = server.ListenAndServe()
+	if err != nil && err != http.ErrServerClosed {
+		log.Fatalf("error starting HTTP server: %v", err)
+	}
+
+	// Wait for server context to be stopped
+	<-serverCtx.Done()
+
+	d.Exit()
+}
+
+func httpService() http.Handler {
+	log.Println("setting up HTTP router and adding middleware")
+
+	r := chi.NewRouter()
+	r.Use(middleware.Logger)
+	r.Use(middleware.Recoverer)
+	r.Use(middleware.Heartbeat("/ping"))
+	r.Use(middleware.AllowContentType("application/json"))
+
+	log.Println("setting up HTTP POST endpoint to enqueue jobs")
+
+	r.Post("/enqueue/{id}", func(w http.ResponseWriter, r *http.Request) {
+		job := getJob(chi.URLParam(r, "id"))
+
+		if job == nil {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		jobQueue <- job
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	return r
+}
+
+func getJob(id string) worker.Job {
+	if id == "helm-revisions-count-tracker" {
+		newJob, err := jobs.NewHelmRevisionsCountTracker(time.Now().UTC(), &jobs.HelmRevisionsCountTrackerOpts{
+			DBConf:             &envDecoder.DBConf,
+			DOClientID:         envDecoder.DOClientID,
+			DOClientSecret:     envDecoder.DOClientSecret,
+			DOScopes:           []string{"read", "write"},
+			ServerURL:          envDecoder.ServerURL,
+			AWSAccessKeyID:     envDecoder.AWSAccessKeyID,
+			AWSSecretAccessKey: envDecoder.AWSSecretAccessKey,
+			AWSRegion:          envDecoder.AWSRegion,
+			S3BucketName:       envDecoder.S3BucketName,
+			EncryptionKey:      envDecoder.EncryptionKey,
+		})
+
+		if err != nil {
+			log.Printf("error creating job with ID: helm-revisions-count-tracker. Error: %v", err)
+			return nil
+		}
+
+		return newJob
+	}
+
+	return nil
+}