Bladeren bron

[POR-2201] add endpoint to attach env group to list of apps (#4165)

Feroze Mohideen 2 jaren geleden
bovenliggende
commit
ff66b3c267

+ 158 - 0
api/server/handlers/porter_app/attach_env_group.go

@@ -0,0 +1,158 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AttachEnvGroupHandler is the handler for the /apps/attach-env-group endpoint
+type AttachEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAttachEnvGroupHandler handles POST requests to the endpoint /apps/attach-env-group
+func NewAttachEnvGroupHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AttachEnvGroupHandler {
+	return &AttachEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AttachEnvGroupRequest is the request object for the /apps/attach-env-group endpoint
+type AttachEnvGroupRequest struct {
+	EnvGroupName   string   `json:"env_group_name"`
+	AppInstanceIDs []string `json:"app_instance_ids"`
+}
+
+// ServeHTTP translates the request into a AttachEnvGroup request, then calls update on the app with the env group
+// The latest version of the env group will be attached (ccp makes sure of that)
+func (c *AttachEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-attach-env-group")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &AttachEnvGroupRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-name", Value: request.EnvGroupName})
+
+	usingUpdateLogic := project.GetFeatureFlag(models.BetaFeaturesEnabled, c.Config().LaunchDarklyClient)
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "using-update-logic", Value: usingUpdateLogic})
+
+	if request.EnvGroupName == "" {
+		err := telemetry.Error(ctx, span, nil, "env group name cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	for _, appInstanceId := range request.AppInstanceIDs {
+		appInstance, err := c.Repo().AppInstance().Get(ctx, appInstanceId)
+		if err != nil {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+			err := telemetry.Error(ctx, span, err, "error getting app instance")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		// TODO: delete second branch once all projects are on update flow
+		if usingUpdateLogic {
+			updateReq := connect.NewRequest(&porterv1.UpdateAppRequest{
+				ProjectId: int64(project.ID),
+				DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+					Id: appInstance.DeploymentTargetID.String(),
+				},
+				App: &porterv1.PorterApp{
+					Name: appInstance.Name,
+					EnvGroups: []*porterv1.EnvGroup{
+						{
+							Name: request.EnvGroupName,
+						},
+					},
+				},
+			})
+
+			_, err = c.Config().ClusterControlPlaneClient.UpdateApp(ctx, updateReq)
+			if err != nil {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+				err := telemetry.Error(ctx, span, err, "error calling ccp update app")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+		} else {
+			validateReq := connect.NewRequest(&porterv1.ValidatePorterAppRequest{
+				ProjectId:          int64(project.ID),
+				DeploymentTargetId: appInstance.DeploymentTargetID.String(),
+				App: &porterv1.PorterApp{
+					Name: appInstance.Name,
+					EnvGroups: []*porterv1.EnvGroup{
+						{
+							Name: request.EnvGroupName,
+						},
+					},
+				},
+			})
+			ccpResp, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateReq)
+			if err != nil {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+				err := telemetry.Error(ctx, span, err, "error calling ccp validate porter app")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			if ccpResp == nil {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+				err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+			if ccpResp.Msg == nil {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+				err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			if ccpResp.Msg.App == nil {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+				err := telemetry.Error(ctx, span, err, "ccp resp app is nil")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			applyReq := connect.NewRequest(&porterv1.ApplyPorterAppRequest{
+				ProjectId:          int64(project.ID),
+				DeploymentTargetId: appInstance.DeploymentTargetID.String(),
+				App:                ccpResp.Msg.App,
+			})
+			_, err = c.Config().ClusterControlPlaneClient.ApplyPorterApp(ctx, applyReq)
+			if err != nil {
+				telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: appInstanceId})
+				err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+		}
+	}
+
+	c.WriteResult(w, r, nil)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -804,6 +804,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/attach-env-group -> porter_app.NewAttachEnvGroupHandler
+	attachEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/attach-env-group", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	attachEnvGroupHandler := porter_app.NewAttachEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: attachEnvGroupEndpoint,
+		Handler:  attachEnvGroupHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/rollback -> porter_app.NewRollbackAppRevisionHandler
 	rollbackAppRevisionEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 2
dashboard/src/main/home/database-dashboard/tabs/Resources.tsx

@@ -4,8 +4,7 @@ import styled from "styled-components";
 import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-
-import { type InstanceTier, type ResourceOption } from "../forms/types";
+import { type InstanceTier, type ResourceOption } from "lib/databases/types";
 
 type Props = {
   options: ResourceOption[];

+ 13 - 0
internal/repository/app_instance.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"context"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AppInstanceRepository represents the set of queries on the AppInstance model
+type AppInstanceRepository interface {
+	// Get returns an app instance by its id
+	Get(ctx context.Context, id string) (*models.AppInstance, error)
+}

+ 40 - 0
internal/repository/gorm/app_instance.go

@@ -0,0 +1,40 @@
+package gorm
+
+import (
+	"context"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gorm.io/gorm"
+)
+
+// AppInstanceRepository uses gorm.DB for querying the database
+type AppInstanceRepository struct {
+	db *gorm.DB
+}
+
+// NewAppInstanceRepository returns a AppInstanceRepository which uses
+// gorm.DB for querying the database
+func NewAppInstanceRepository(db *gorm.DB) repository.AppInstanceRepository {
+	return &AppInstanceRepository{db}
+}
+
+// Get returns an app instance by its id
+func (repo *AppInstanceRepository) Get(ctx context.Context, id string) (*models.AppInstance, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-get-app-instance")
+	defer span.End()
+
+	appInstance := &models.AppInstance{}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-instance-id", Value: id})
+	if id == "" {
+		return nil, telemetry.Error(ctx, span, nil, "id is empty")
+	}
+
+	if err := repo.db.Where("id = ?", id).First(&appInstance).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error getting app instance")
+	}
+
+	return appInstance, nil
+}

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

@@ -58,6 +58,7 @@ type GormRepository struct {
 	appTemplate               repository.AppTemplateRepository
 	githubWebhook             repository.GithubWebhookRepository
 	datastore                 repository.DatastoreRepository
+	appInstance               repository.AppInstanceRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -269,6 +270,11 @@ func (t *GormRepository) Datastore() repository.DatastoreRepository {
 	return t.datastore
 }
 
+// AppInstance returns the AppInstanceRepository interface implemented by gorm
+func (t *GormRepository) AppInstance() repository.AppInstanceRepository {
+	return t.appInstance
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -324,5 +330,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		appTemplate:               NewAppTemplateRepository(db),
 		githubWebhook:             NewGithubWebhookRepository(db),
 		datastore:                 NewDatastoreRepository(db),
+		appInstance:               NewAppInstanceRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -52,4 +52,5 @@ type Repository interface {
 	AppTemplate() AppTemplateRepository
 	GithubWebhook() GithubWebhookRepository
 	Datastore() DatastoreRepository
+	AppInstance() AppInstanceRepository
 }

+ 24 - 0
internal/repository/test/app_instance.go

@@ -0,0 +1,24 @@
+package test
+
+import (
+	"context"
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// AppInstanceRepository is a test repository that implements repository.AppInstanceRepository
+type AppInstanceRepository struct {
+	canQuery bool
+}
+
+// NewAppInstanceRepository returns the test AppInstanceRepository
+func NewAppInstanceRepository() repository.AppInstanceRepository {
+	return &AppInstanceRepository{canQuery: false}
+}
+
+// Get returns an app instance by its id
+func (repo *AppInstanceRepository) Get(ctx context.Context, id string) (*models.AppInstance, error) {
+	return nil, errors.New("cannot read database")
+}

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

@@ -56,6 +56,7 @@ type TestRepository struct {
 	appTemplate               repository.AppTemplateRepository
 	githubWebhook             repository.GithubWebhookRepository
 	datastore                 repository.DatastoreRepository
+	appInstance               repository.AppInstanceRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -267,6 +268,11 @@ func (t *TestRepository) Datastore() repository.DatastoreRepository {
 	return t.datastore
 }
 
+// AppInstance returns a test AppInstanceRepository
+func (t *TestRepository) AppInstance() repository.AppInstanceRepository {
+	return t.appInstance
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -322,5 +328,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		appTemplate:               NewAppTemplateRepository(),
 		githubWebhook:             NewGithubWebhookRepository(),
 		datastore:                 NewDatastoreRepository(),
+		appInstance:               NewAppInstanceRepository(),
 	}
 }