Просмотр исходного кода

Merge branch 'belanger/add-env-group-to-stacks' of github.com:porter-dev/porter into nico/por-559-support-environment-group-creation-from

jnfrati 3 лет назад
Родитель
Сommit
29e9c4fc52

+ 15 - 1
.github/workflows/release.yaml

@@ -3,6 +3,20 @@ on:
     types: [released]
 name: Update binaries 
 jobs:
+  update-self-hosted-helm-registry:
+    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: Run workflow
+        run: gh workflow run release.yaml --repo porter-dev/porter-self-hosted -f version=${{steps.tag_name.outputs.tag}}
+        env:
+          GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}  
   push-docker-server-latest:
     runs-on: ubuntu-latest
     steps:
@@ -95,4 +109,4 @@ jobs:
 
           git add Formula
           git commit -m "Update to version ${{steps.tag_name.outputs.tag}}"
-          git push origin main
+          git push origin main

+ 20 - 0
api/client/environment.go

@@ -118,6 +118,26 @@ func (c *Client) FinalizeDeployment(
 	return resp, err
 }
 
+func (c *Client) FinalizeDeploymentWithErrors(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.FinalizeDeploymentWithErrorsRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/finalize_errors",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) DeleteDeployment(
 	ctx context.Context,
 	projID, clusterID, deploymentID uint,

+ 42 - 5
api/server/handlers/environment/finalize_deployment.go

@@ -104,11 +104,46 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
+	workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Subdomain == "" {
+		depl.Subdomain = "*Ingress is disabled for this deployment*"
+	}
+
 	// write comment in PR
-	commentBody := fmt.Sprintf("Porter has deployed this pull request to the following URL:\n%s", depl.Subdomain)
-	prComment := github.IssueComment{
-		Body: &commentBody,
-		User: &github.User{},
+	commentBody := fmt.Sprintf(
+		"## Porter Preview Environments\n"+
+			"✅ All changes deployed successfully\n"+
+			"||Deployment Information|\n"+
+			"|-|-|\n"+
+			"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
+			"| Live URL | %s |\n"+
+			"| Build Logs | %s |\n"+
+			"| Porter Deployments URL | %s/preview-environments/details/%s?environment_id=%d |",
+		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain, workflowRun.GetHTMLURL(),
+		c.Config().ServerConf.ServerURL, depl.Namespace, depl.EnvironmentID,
+	)
+
+	if len(request.SuccessfulResources) > 0 {
+		commentBody += "\n#### Successfully deployed resources\n"
+
+		for _, res := range request.SuccessfulResources {
+			if res.ReleaseType == "job" {
+				commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			} else {
+				commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			}
+		}
 	}
 
 	_, _, err = client.Issues.CreateComment(
@@ -116,7 +151,9 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		env.GitRepoOwner,
 		env.GitRepoName,
 		int(depl.PullRequestID),
-		&prComment,
+		&github.IssueComment{
+			Body: github.String(commentBody),
+		},
 	)
 
 	if err != nil {

+ 166 - 0
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -0,0 +1,166 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+	"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/commonutils"
+	"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/models/integrations"
+	"gorm.io/gorm"
+)
+
+type FinalizeDeploymentWithErrorsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewFinalizeDeploymentWithErrorsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *FinalizeDeploymentWithErrorsHandler {
+	return &FinalizeDeploymentWithErrorsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	request := &types.FinalizeDeploymentWithErrorsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if len(request.Errors) == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("at least one error is required to report"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no environment found")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no deployment found for environment ID: %d, namespace: %s", env.ID, request.Namespace)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Status = types.DeploymentStatusFailed
+
+	// we do not care of the error in this case because the list deployments endpoint
+	// talks to the github API to fetch the deployment status correctly
+	c.Repo().Environment().UpdateDeployment(depl)
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("unable to get github client: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	// FIXME: ignore the status of thie API call for now
+	client.Repositories.CreateDeploymentStatus(
+		context.Background(), owner, name, depl.GHDeploymentID, &github.DeploymentStatusRequest{
+			State:       github.String("failure"),
+			Description: github.String("one or more resources failed to build"),
+		},
+	)
+
+	workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	commentBody := fmt.Sprintf(
+		"## Porter Preview Environments\n"+
+			"❌ Errors encountered while deploying the changes\n"+
+			"||Deployment Information|\n"+
+			"|-|-|\n"+
+			"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
+			"| Build Logs | %s |\n",
+		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, workflowRun.GetHTMLURL(),
+	)
+
+	if len(request.SuccessfulResources) > 0 {
+		commentBody += "#### Successfully deployed resources\n"
+
+		for _, res := range request.SuccessfulResources {
+			if res.ReleaseType == "job" {
+				commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			} else {
+				commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			}
+		}
+	}
+
+	commentBody += "#### Failed resources\n"
+
+	for res, err := range request.Errors {
+		commentBody += fmt.Sprintf("<details>\n  <summary><code>%s</code></summary>\n\n  **Error:** %s\n</details>\n", res, err)
+	}
+
+	_, _, err = client.Issues.CreateComment(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		int(depl.PullRequestID),
+		&github.IssueComment{
+			Body: github.String(commentBody),
+		},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error creating github comment: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 14 - 0
api/server/handlers/namespace/create_env_group.go

@@ -20,6 +20,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
 )
 
 type CreateEnvGroupHandler struct {
@@ -115,6 +116,13 @@ func (c *CreateEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(fmt.Errorf(strings.Join(errStrArr, ","))))
 		return
 	}
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, envGroup)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }
 
 func rolloutApplications(
@@ -367,3 +375,9 @@ func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]inte
 
 	return res, nil
 }
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, envGroup *types.EnvGroup) error {
+	// update the relevant env group version number if tied to a stack resource
+	return stacks.UpdateEnvGroupVersion(config, projectID, clusterID, envGroup)
+}

+ 121 - 43
api/server/handlers/stack/create.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 
 	helmrelease "helm.sh/helm/v3/pkg/release"
@@ -73,6 +74,13 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	envGroups, err := getEnvGroupModels(req.EnvGroups, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// write stack to the database with creating status
 	stack := &models.Stack{
 		ProjectID: proj.ID,
@@ -86,6 +94,7 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 				Status:         string(types.StackRevisionStatusDeploying),
 				SourceConfigs:  sourceConfigs,
 				Resources:      resources,
+				EnvGroups:      envGroups,
 			},
 		},
 	}
@@ -97,76 +106,98 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// apply all app resources
-	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	// apply all env groups
+	k8sAgent, err := p.GetAgent(r, cluster, "")
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+	envGroupDeployErrors := make([]string, 0)
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+	for _, envGroup := range req.EnvGroups {
+		cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+			Name:            envGroup.Name,
+			Namespace:       namespace,
+			Variables:       envGroup.Variables,
+			SecretVariables: envGroup.SecretVariables,
+		})
 
-	helmReleaseMap := make(map[string]*helmrelease.Release)
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", envGroup.Name))
+		}
 
-	deployErrs := make([]string, 0)
+		// add each of the linked applications to the env group
+		for _, appName := range envGroup.LinkedApplications {
 
-	for _, appResource := range req.AppResources {
-		rel, err := applyAppResource(&applyAppResourceOpts{
-			config:     p.Config(),
-			projectID:  proj.ID,
-			namespace:  namespace,
-			cluster:    cluster,
-			registries: registries,
-			helmAgent:  helmAgent,
-			request:    appResource,
-		})
+			cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
 
-		if err != nil {
-			deployErrs = append(deployErrs, err.Error())
-		} else {
-			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+			if err != nil {
+				envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", envGroup.Name))
+			}
 		}
 	}
 
-	// update stack revision status
 	revision := &stack.Revisions[0]
 
-	if len(deployErrs) > 0 {
+	if len(envGroupDeployErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
-		revision.Reason = "DeployError"
-		revision.Message = strings.Join(deployErrs, " , ")
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+
+		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 	} else {
-		revision.Status = string(types.StackRevisionStatusDeployed)
-	}
+		// apply all app resources
+		registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
 
-	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
+		helmAgent, err := p.GetHelmAgent(r, cluster, "")
 
-	saveErrs := make([]string, 0)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		helmReleaseMap := make(map[string]*helmrelease.Release)
 
-	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)
+		deployErrs := make([]string, 0)
+
+		for _, appResource := range req.AppResources {
+			rel, err := applyAppResource(&applyAppResourceOpts{
+				config:     p.Config(),
+				projectID:  proj.ID,
+				namespace:  namespace,
+				cluster:    cluster,
+				registries: registries,
+				helmAgent:  helmAgent,
+				request:    appResource,
+			})
 
 			if err != nil {
-				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+				deployErrs = append(deployErrs, err.Error())
+			} else {
+				helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
 			}
 		}
-	}
 
-	if len(saveErrs) > 0 {
-		revision.Reason = "SaveError"
-		revision.Message = strings.Join(saveErrs, " , ")
+		// 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)
 
@@ -174,6 +205,30 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			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, " , ")
+
+			revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+			if err != nil {
+				p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+		}
 	}
 
 	// read the stack again to get the latest revision info
@@ -248,3 +303,26 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 
 	return res, nil
 }
+
+func getEnvGroupModels(envGroups []*types.CreateStackEnvGroupRequest, projID, clusterID uint, namespace string) ([]models.StackEnvGroup, error) {
+	res := make([]models.StackEnvGroup, 0)
+
+	for _, envGroup := range envGroups {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackEnvGroup{
+			Name:            envGroup.Name,
+			UID:             uid,
+			EnvGroupVersion: 1,
+			ProjectID:       projID,
+			ClusterID:       clusterID,
+			Namespace:       namespace,
+		})
+	}
+
+	return res, nil
+}

+ 36 - 0
api/server/handlers/stack/list_revisions.go

@@ -0,0 +1,36 @@
+package stack
+
+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/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackListRevisionsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackListRevisionsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackListRevisionsHandler {
+	return &StackListRevisionsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackListRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	res := make([]types.StackRevision, 0)
+
+	for _, stackRev := range stack.Revisions {
+		res = append(res, *stackRev.ToStackRevisionType(stack.UID))
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 12 - 1
api/server/handlers/stack/rollback.go

@@ -1,6 +1,7 @@
 package stack
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -86,8 +87,16 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	revision.SourceConfigs = newSourceConfigs
 	revision.Resources = appResources
+	revision.EnvGroups = envGroups
 
 	revision, err = p.Repo().Stack().AppendNewRevision(revision)
 
@@ -114,9 +123,11 @@ func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	if len(rollbackErrors) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "RollbackError"
-		revision.Message = strings.Join(rollbackErrors, " , ")
+		revision.Message = fmt.Sprintf("Error while rolling back to version %d: %s", req.TargetRevision, strings.Join(rollbackErrors, " , "))
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "Rollback"
+		revision.Message = fmt.Sprintf("The stack was rolled back to version %d", req.TargetRevision)
 	}
 
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

+ 4 - 1
api/server/handlers/stack/update_source_put.go

@@ -1,6 +1,7 @@
 package stack
 
 import (
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -130,9 +131,11 @@ func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	if len(deployErrs) > 0 {
 		revision.Status = string(types.StackRevisionStatusFailed)
 		revision.Reason = "DeployError"
-		revision.Message = strings.Join(deployErrs, " , ")
+		revision.Message = fmt.Sprintf("Error while updating source configuration: %s", strings.Join(deployErrs, " , "))
 	} else {
 		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "SourceConfigUpdate"
+		revision.Message = fmt.Sprintf("The source configuration was updated")
 	}
 
 	revision, err = p.Repo().Stack().UpdateStackRevision(revision)

+ 36 - 0
api/server/router/git_installation.go

@@ -367,6 +367,42 @@ func getGitInstallationRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize_errors ->
+		// environment.NewFinalizeDeploymentWithErrorsHandler
+		finalizeDeploymentWithErrorsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize_errors",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		finalizeDeploymentWithErrorsHandler := environment.NewFinalizeDeploymentWithErrorsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: finalizeDeploymentWithErrorsEndpoint,
+			Handler:  finalizeDeploymentWithErrorsHandler,
+			Router:   r,
+		})
+
 		// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
 		// environment.NewDeleteEnvironmentHandler
 		deleteEnvironmentEndpoint := factory.NewAPIEndpoint(

+ 57 - 5
api/server/router/v1/stack.go

@@ -58,11 +58,11 @@ type stackRevisionPathParams struct {
 	// required: true
 	StackID string `json:"stack_id"`
 
-	// The stack revision number
+	// The stack revision id
 	// in: path
 	// required: true
 	// minimum: 1
-	StackRevisionNumber string `json:"stack_revision_number"`
+	RevisionID string `json:"revision_id"`
 }
 
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
@@ -267,8 +267,60 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
-	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} -> stack.NewStackGetRevisionHandler
-	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} getStackRevision
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/revisions -> stack.NewStackListRevisionsHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/revisions listStackRevisions
+	//
+	// Lists revisions in a stack. A max of 100 revisions will be returned, sorted from most recent to least recent.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List stack revisions
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed stack revisions
+	//     schema:
+	//       $ref: '#/definitions/ListStackRevisionsResponse'
+	//   '403':
+	//     description: Forbidden
+	listRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/revisions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	listRevisionsHandler := stack.NewStackListRevisionsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listRevisionsEndpoint,
+		Handler:  listRevisionsHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{revision_id} -> stack.NewStackGetRevisionHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{revision_id} getStackRevision
 	//
 	// Gets a stack revision
 	//
@@ -283,7 +335,7 @@ func getV1StackRoutes(
 	//   - name: cluster_id
 	//   - name: namespace
 	//   - name: stack_id
-	//   - name: stack_revision_number
+	//   - name: revision_id
 	// responses:
 	//   '200':
 	//     description: Successfully got the stack revision

+ 14 - 2
api/types/environment.go

@@ -69,9 +69,21 @@ type CreateDeploymentRequest struct {
 	PullRequestID uint   `json:"pull_request_id" form:"required"`
 }
 
+type SuccessfullyDeployedResource struct {
+	ReleaseName string `json:"release_name" form:"required"`
+	ReleaseType string `json:"release_type"`
+}
+
 type FinalizeDeploymentRequest struct {
-	Namespace string `json:"namespace" form:"required"`
-	Subdomain string `json:"subdomain" form:"required"`
+	Namespace           string                          `json:"namespace" form:"required"`
+	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
+	Subdomain           string                          `json:"subdomain"`
+}
+
+type FinalizeDeploymentWithErrorsRequest struct {
+	Namespace           string                          `json:"namespace" form:"required"`
+	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
+	Errors              map[string]string               `json:"errors" form:"required"`
 }
 
 type UpdateDeploymentRequest struct {

+ 54 - 1
api/types/stacks.go

@@ -17,6 +17,9 @@ type CreateStackRequest struct {
 	// registry or linked to a remote Git repository.
 	// required: true
 	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+
+	// A list of env groups which can be synced to an application
+	EnvGroups []*CreateStackEnvGroupRequest `json:"env_groups,omitempty" form:"required,dive,required"`
 }
 
 // swagger:model
@@ -84,6 +87,9 @@ type Stack struct {
 	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
 }
 
+// swagger:model
+type ListStackRevisionsResponse []StackRevision
+
 // swagger:model
 type StackListResponse []Stack
 
@@ -109,7 +115,7 @@ type StackResource struct {
 	// If this is an app resource, app-specific information for the resource
 	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
 
-	// The source configuration for this stack
+	// The source configuration that this resource uses, if this is an application resource
 	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
 }
 
@@ -158,7 +164,34 @@ type StackRevision struct {
 	// The list of resources deployed in this revision
 	Resources []StackResource `json:"resources"`
 
+	// The list of source configs deployed in this revision
 	SourceConfigs []StackSourceConfig `json:"source_configs"`
+
+	// The list of env groups scoped to this stack
+	EnvGroups []StackEnvGroup `json:"env_groups"`
+}
+
+type StackEnvGroup struct {
+	// The time that this resource was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that this resource was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this resource belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this resource belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The name of the resource
+	Name string `json:"name"`
+
+	// The id for this resource
+	ID string `json:"id"`
+
+	// The version of the env group which is being used
+	EnvGroupVersion uint `json:"env_group_version"`
 }
 
 type StackSourceConfig struct {
@@ -190,6 +223,26 @@ type StackSourceConfig struct {
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 }
 
+// swagger:model
+type CreateStackEnvGroupRequest struct {
+	// The name of the env group
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// The non-secret variables to set in the env group
+	// required: true
+	Variables map[string]string `json:"variables,required" form:"required"`
+
+	// The secret variables to set in the env group
+	// required: true
+	SecretVariables map[string]string `json:"secret_variables,required" form:"required"`
+
+	// The list of applications that this env group should be synced to. These applications **must** be present
+	// in the stack - if an env group is created from a stack, syncing to applications which are not in the stack
+	// is not supported
+	LinkedApplications []string `json:"linked_applications"`
+}
+
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 	// required: true

+ 85 - 7
cli/cmd/apply.go

@@ -859,15 +859,28 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 		}
 	}
 
+	req := &types.FinalizeDeploymentRequest{
+		Namespace: t.namespace,
+		Subdomain: strings.Join(subdomains, ", "),
+	}
+
+	for _, res := range t.resourceGroup.Resources {
+		releaseType := getReleaseType(res)
+		releaseName := getReleaseName(res)
+
+		if releaseType != "" && releaseName != "" {
+			req.SuccessfulResources = append(req.SuccessfulResources, &types.SuccessfullyDeployedResource{
+				ReleaseName: releaseName,
+				ReleaseType: releaseType,
+			})
+		}
+	}
+
 	// finalize the deployment
 	_, err := t.client.FinalizeDeployment(
 		context.Background(),
 		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName,
-		&types.FinalizeDeploymentRequest{
-			Namespace: t.namespace,
-			Subdomain: strings.Join(subdomains, ","),
-		},
+		t.repoOwner, t.repoName, req,
 	)
 
 	return err
@@ -900,6 +913,45 @@ func (t *DeploymentHook) OnError(err error) {
 	}
 }
 
+func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
+	// if the deployment exists, throw an error for that deployment
+	_, getDeplErr := t.client.GetDeployment(
+		context.Background(),
+		t.projectID, t.clusterID, t.envID,
+		&types.GetDeploymentRequest{
+			Namespace: t.namespace,
+		},
+	)
+
+	if getDeplErr == nil {
+		req := &types.FinalizeDeploymentWithErrorsRequest{
+			Namespace: t.namespace,
+			Errors:    make(map[string]string),
+		}
+
+		for _, res := range t.resourceGroup.Resources {
+			if _, ok := allErrors[res.Name]; !ok {
+				req.SuccessfulResources = append(req.SuccessfulResources, &types.SuccessfullyDeployedResource{
+					ReleaseName: getReleaseName(res),
+					ReleaseType: getReleaseType(res),
+				})
+			}
+		}
+
+		for res, err := range allErrors {
+			req.Errors[res] = err.Error()
+		}
+
+		// FIXME: handle the error
+		t.client.FinalizeDeploymentWithErrors(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			req,
+		)
+	}
+}
+
 type CloneEnvGroupHook struct {
 	client   *api.Client
 	resGroup *switchboardTypes.ResourceGroup
@@ -984,8 +1036,34 @@ func (t *CloneEnvGroupHook) DataQueries() map[string]interface{} {
 	return nil
 }
 
-func (t *CloneEnvGroupHook) PostApply(populatedData map[string]interface{}) error {
+func (t *CloneEnvGroupHook) PostApply(map[string]interface{}) error {
 	return nil
 }
 
-func (t *CloneEnvGroupHook) OnError(err error) {}
+func (t *CloneEnvGroupHook) OnError(error) {}
+
+func (t *CloneEnvGroupHook) OnConsolidatedErrors(map[string]error) {}
+
+func getReleaseName(res *switchboardTypes.Resource) string {
+	// can ignore the error because this method is called once
+	// GetTarget has alrealy been called and validated previously
+	target, _ := preview.GetTarget(res.Target)
+
+	if target.AppName != "" {
+		return target.AppName
+	}
+
+	return res.Name
+}
+
+func getReleaseType(res *switchboardTypes.Resource) string {
+	// can ignore the error because this method is called once
+	// GetSource has alrealy been called and validated previously
+	source, _ := preview.GetSource(res.Source)
+
+	if source != nil && source.Name != "" {
+		return source.Name
+	}
+
+	return ""
+}

+ 1 - 1
go.mod

@@ -39,7 +39,7 @@ require (
 	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
 	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/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f
 	github.com/rs/zerolog v1.26.0
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
 	github.com/spf13/cobra v1.4.0

+ 2 - 0
go.sum

@@ -1388,6 +1388,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb h1:WNKCA31IJaGnf0VR0uzb3b10IzQb3OSuGlFi8X/AnLs=
 github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
+github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f h1:REYJSDm2R3pM4mq88AlSBPIPhGiKFwiehe+GKZIc7Hc=
+github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=

+ 39 - 0
internal/models/stack.go

@@ -62,6 +62,8 @@ type StackRevision struct {
 	Resources []StackResource
 
 	SourceConfigs []StackSourceConfig
+
+	EnvGroups []StackEnvGroup
 }
 
 func (s StackRevision) ToStackRevisionMetaType(stackID string) types.StackRevisionMeta {
@@ -88,10 +90,17 @@ func (s StackRevision) ToStackRevisionType(stackID string) *types.StackRevision
 		resources = append(resources, *stackResource.ToStackResource(stackID, s.RevisionNumber, s.SourceConfigs))
 	}
 
+	envGroups := make([]types.StackEnvGroup, 0)
+
+	for _, stackEnvGroup := range s.EnvGroups {
+		envGroups = append(envGroups, *stackEnvGroup.ToStackEnvGroupType(stackID, s.RevisionNumber))
+	}
+
 	return &types.StackRevision{
 		StackRevisionMeta: &metaType,
 		SourceConfigs:     sourceConfigs,
 		Resources:         resources,
+		EnvGroups:         envGroups,
 		Reason:            s.Reason,
 		Message:           s.Message,
 	}
@@ -176,3 +185,33 @@ func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevision
 		ImageTag:        s.ImageTag,
 	}
 }
+
+type StackEnvGroup struct {
+	gorm.Model
+
+	StackRevisionID uint
+
+	Name string
+
+	Namespace string
+
+	ProjectID uint
+
+	ClusterID uint
+
+	UID string
+
+	EnvGroupVersion uint
+}
+
+func (s StackEnvGroup) ToStackEnvGroupType(stackID string, stackRevisionID uint) *types.StackEnvGroup {
+	return &types.StackEnvGroup{
+		CreatedAt:       s.CreatedAt,
+		UpdatedAt:       s.UpdatedAt,
+		StackID:         stackID,
+		StackRevisionID: stackRevisionID,
+		Name:            s.Name,
+		ID:              s.UID,
+		EnvGroupVersion: s.EnvGroupVersion,
+	}
+}

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

@@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.StackRevision{},
 		&models.StackResource{},
 		&models.StackSourceConfig{},
+		&models.StackEnvGroup{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 15 - 3
internal/repository/gorm/stack.go

@@ -49,7 +49,7 @@ func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace str
 	// query for each stack's revision
 	revisions := make([]*models.StackRevision, 0)
 
-	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
+	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Preload("EnvGroups").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
 	stack_revisions.id IN (
 	  SELECT s2.id FROM (SELECT MAX(stack_revisions.id) id FROM stack_revisions WHERE stack_revisions.stack_id IN (?) GROUP BY stack_revisions.stack_id) s2
 	)
@@ -83,6 +83,7 @@ func (repo *StackRepository) ReadStackByID(projectID, stackID uint) (*models.Sta
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.id = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
@@ -100,6 +101,7 @@ func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string)
 		}).
 		Preload("Revisions.Resources").
 		Preload("Revisions.SourceConfigs").
+		Preload("Revisions.EnvGroups").
 		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
@@ -127,7 +129,7 @@ func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision)
 func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 
-	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Preload("EnvGroups").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
 		return nil, err
 	}
 
@@ -137,7 +139,7 @@ func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.St
 func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
 	revision := &models.StackRevision{}
 
-	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Preload("EnvGroups").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
 		return nil, err
 	}
 
@@ -181,3 +183,13 @@ func (repo *StackRepository) UpdateStackResource(resource *models.StackResource)
 
 	return resource, nil
 }
+
+func (repo *StackRepository) ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error) {
+	envGroup := &models.StackEnvGroup{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND namespace = ? AND name = ?", projectID, clusterID, namespace, name).Order("id desc").First(&envGroup).Error; err != nil {
+		return nil, err
+	}
+
+	return envGroup, nil
+}

+ 2 - 0
internal/repository/stack.go

@@ -17,4 +17,6 @@ type StackRepository interface {
 
 	ReadStackResource(resourceID uint) (*models.StackResource, error)
 	UpdateStackResource(resource *models.StackResource) (*models.StackResource, error)
+
+	ReadStackEnvGroupFirstMatch(projectID, clusterID uint, namespace, name string) (*models.StackEnvGroup, error)
 }

+ 23 - 0
internal/stacks/helpers.go

@@ -76,3 +76,26 @@ func CloneAppResources(
 
 	return res, nil
 }
+
+func CloneEnvGroups(envGroups []models.StackEnvGroup) ([]models.StackEnvGroup, error) {
+	res := make([]models.StackEnvGroup, 0)
+
+	for _, envGroup := range envGroups {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackEnvGroup{
+			UID:             uid,
+			Name:            envGroup.Name,
+			EnvGroupVersion: envGroup.EnvGroupVersion,
+			Namespace:       envGroup.Namespace,
+			ProjectID:       envGroup.ProjectID,
+			ClusterID:       envGroup.ClusterID,
+		})
+	}
+
+	return res, nil
+}

+ 83 - 0
internal/stacks/hooks.go

@@ -1,9 +1,11 @@
 package stacks
 
 import (
+	"errors"
 	"fmt"
 
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -65,10 +67,91 @@ func UpdateHelmRevision(config *config.Config, projID, clusterID uint, rel *rele
 		}
 	}
 
+	clonedEnvGroups, err := CloneEnvGroups(stackRevision.EnvGroups)
+
+	if err != nil {
+		return err
+	}
+
+	stackRevision.Model = gorm.Model{}
+	stackRevision.RevisionNumber++
+	stackRevision.Resources = clonedAppResources
+	stackRevision.SourceConfigs = clonedSourceConfigs
+	stackRevision.EnvGroups = clonedEnvGroups
+	stackRevision.Status = "deployed"
+	stackRevision.Reason = "ApplicationUpgrade"
+	stackRevision.Message = fmt.Sprintf("The application %s was updated from version %d to %d", rel.Name, rel.Version-1, rel.Version)
+
+	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)
+
+	return err
+}
+
+func UpdateEnvGroupVersion(config *config.Config, projID, clusterID uint, envGroup *types.EnvGroup) error {
+	// read stack env group by params
+	stackEnvGroup, err := config.Repo.Stack().ReadStackEnvGroupFirstMatch(projID, clusterID, envGroup.Namespace, envGroup.Name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil
+		}
+
+		return err
+	}
+
+	// read the revision number corresponding and create a new revision of the stack
+	oldStackRevision, err := config.Repo.Stack().ReadStackRevision(stackEnvGroup.StackRevisionID)
+
+	if err != nil {
+		return err
+	}
+
+	// get the latest revision for that stack
+	stack, err := config.Repo.Stack().ReadStackByID(projID, oldStackRevision.StackID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(stack.Revisions) == 0 {
+		return fmt.Errorf("length of stack revision list was 0")
+	}
+
+	currStackRevision := stack.Revisions[0]
+	stackRevision := &currStackRevision
+
+	clonedSourceConfigs, err := CloneSourceConfigs(stackRevision.SourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedAppResources, err := CloneAppResources(stackRevision.Resources, stackRevision.SourceConfigs, clonedSourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedEnvGroups, err := CloneEnvGroups(stackRevision.EnvGroups)
+
+	if err != nil {
+		return err
+	}
+
+	for _, clonedEnvGroup := range clonedEnvGroups {
+		if clonedEnvGroup.Name == envGroup.Name {
+			clonedEnvGroup.EnvGroupVersion = envGroup.Version
+		}
+	}
+
 	stackRevision.Model = gorm.Model{}
 	stackRevision.RevisionNumber++
 	stackRevision.Resources = clonedAppResources
 	stackRevision.SourceConfigs = clonedSourceConfigs
+	stackRevision.EnvGroups = clonedEnvGroups
+	stackRevision.Reason = "EnvGroupUpgrade"
+	stackRevision.Message = fmt.Sprintf("The environment group %s was updated from version %d to %d", envGroup.Name, envGroup.Version-1, envGroup.Version)
+	stackRevision.Status = "deployed"
 
 	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)