Переглянути джерело

[POR-2199] Refactor DB get, list and delete endpoints (#4159)

Feroze Mohideen 2 роки тому
батько
коміт
1ba29afae8
38 змінених файлів з 1298 додано та 1045 видалено
  1. 9 0
      api/server/handlers/cloud_provider/list_aws.go
  2. 63 52
      api/server/handlers/datastore/delete.go
  3. 126 0
      api/server/handlers/datastore/get.go
  4. 75 69
      api/server/handlers/datastore/list.go
  5. 3 3
      api/server/handlers/datastore/update.go
  6. 1 146
      api/server/handlers/release/create_addon.go
  7. 0 87
      api/server/router/datastore.go
  8. 85 0
      api/server/router/project.go
  9. 93 12
      dashboard/src/lib/databases/types.ts
  10. 45 0
      dashboard/src/lib/hooks/useClusterList.ts
  11. 0 85
      dashboard/src/lib/hooks/useDatabase.ts
  12. 49 0
      dashboard/src/lib/hooks/useDatabaseList.ts
  13. 130 0
      dashboard/src/lib/hooks/useDatabaseMethods.ts
  14. 3 4
      dashboard/src/main/home/Home.tsx
  15. 11 10
      dashboard/src/main/home/database-dashboard/CreateDatabase.tsx
  16. 114 0
      dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx
  17. 56 127
      dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx
  18. 3 6
      dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx
  19. 1 2
      dashboard/src/main/home/database-dashboard/DatabaseHeaderItem.tsx
  20. 12 22
      dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx
  21. 11 58
      dashboard/src/main/home/database-dashboard/DatabaseView.tsx
  22. 23 7
      dashboard/src/main/home/database-dashboard/constants.ts
  23. 79 32
      dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx
  24. 8 21
      dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx
  25. 9 22
      dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx
  26. 8 21
      dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx
  27. 7 5
      dashboard/src/main/home/database-dashboard/icons.tsx
  28. 70 65
      dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx
  29. 28 51
      dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx
  30. 0 95
      dashboard/src/main/home/database-dashboard/types.ts
  31. 2 2
      dashboard/src/main/home/database-dashboard/utils.tsx
  32. 15 16
      dashboard/src/main/home/sidebar/Sidebar.tsx
  33. 59 21
      dashboard/src/shared/api.tsx
  34. 4 4
      internal/datastore/create.go
  35. 45 0
      internal/datastore/delete.go
  36. 4 0
      internal/repository/datastore.go
  37. 37 0
      internal/repository/gorm/datastore.go
  38. 10 0
      internal/repository/test/datastore.go

+ 9 - 0
api/server/handlers/cloud_provider/list_aws.go

@@ -4,6 +4,7 @@ import (
 	"net/http"
 
 	"github.com/aws/aws-sdk-go/aws/arn"
+	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"
@@ -34,6 +35,14 @@ type AwsAccount struct {
 	ProjectID uint `json:"project_id"`
 }
 
+// CloudProvider is an abstraction for a cloud provider
+type CloudProvider struct {
+	// Type is the type of the cloud provider
+	Type porterv1.EnumCloudProvider `json:"type"`
+	// AccountID is the ID of the cloud provider account
+	AccountID string `json:"account_id"`
+}
+
 // ListAwsAccountsHandler is a struct for handling an aws cloud provider list request
 type ListAwsAccountsHandler struct {
 	handlers.PorterHandlerWriter

+ 63 - 52
api/server/handlers/datastore/delete.go

@@ -6,20 +6,17 @@ import (
 
 	"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/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/datastore"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// DeleteRequest describes an inbound datastore deletion request
-type DeleteRequest struct {
-	Type string `json:"type" form:"required"`
-	Name string `json:"name" form:"required"`
-}
-
 // DeleteDatastoreHandler is a struct for handling datastore deletion requests
 type DeleteDatastoreHandler struct {
 	handlers.PorterHandlerReadWriter
@@ -39,49 +36,39 @@ func NewDeleteDatastoreHandler(
 }
 
 func (h *DeleteDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-datastore-delete")
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-delete-datastore")
 	defer span.End()
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	request := &StatusRequest{}
-	if ok := h.DecodeAndValidate(w, r, request); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding request")
+	datastoreName, reqErr := requestutils.GetURLParamString(r, types.URLParamDatastoreName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing datastore name")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-name", Value: datastoreName})
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "datastore-name", Value: request.Name},
-		telemetry.AttributeKV{Key: "datastore-type", Value: request.Type},
-	)
-
-	cluster, err := h.getClusterForDatastore(ctx, r, project.ID, request.Name)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "unable to find datastore on any associated cluster")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
-
-	helmAgent, err := h.GetHelmAgent(ctx, r, cluster, "ack-system")
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to get helm client for cluster")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	_, err = helmAgent.GetRelease(ctx, request.Name, 0, false)
+	datastore, err := datastore.DeleteRecord(ctx, datastore.DeleteRecordInput{
+		ProjectID:           project.ID,
+		Name:                datastoreName,
+		DatastoreRepository: h.Repo().Datastore(),
+	})
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to get helm release")
+		err = telemetry.Error(ctx, span, err, "error deleting datastore record")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	_, err = helmAgent.UninstallChart(ctx, request.Name)
+	// TODO: replace this with a CCP call
+	err = h.UninstallDatastore(ctx, UninstallDatastoreInput{
+		ProjectID:                         project.ID,
+		Name:                              datastoreName,
+		CloudProvider:                     datastore.CloudProvider,
+		CloudProviderCredentialIdentifier: datastore.CloudProviderCredentialIdentifier,
+		Request:                           r,
+	})
 	if err != nil {
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
-		err := telemetry.Error(ctx, span, err, "unable to uninstall chart")
+		err = telemetry.Error(ctx, span, err, "error uninstalling datastore")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
@@ -90,30 +77,54 @@ func (h *DeleteDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	w.WriteHeader(http.StatusAccepted)
 }
 
-func (h *DeleteDatastoreHandler) getClusterForDatastore(ctx context.Context, r *http.Request, projectID uint, datastoreName string) (*models.Cluster, error) {
-	ctx, span := telemetry.NewSpan(ctx, "get-cluster-for-datastore")
+// UninstallDatastoreInput is the input type for UninstallDatastore
+type UninstallDatastoreInput struct {
+	ProjectID                         uint
+	Name                              string
+	CloudProvider                     string
+	CloudProviderCredentialIdentifier string
+	Request                           *http.Request
+}
 
-	if r == nil {
-		return nil, telemetry.Error(ctx, span, nil, "missing http request object")
-	}
+// UninstallDatastore uninstalls a datastore from a cluster
+func (h *DeleteDatastoreHandler) UninstallDatastore(ctx context.Context, inp UninstallDatastoreInput) error {
+	ctx, span := telemetry.NewSpan(ctx, "uninstall-datastore")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: inp.ProjectID},
+		telemetry.AttributeKV{Key: "name", Value: inp.Name},
+		telemetry.AttributeKV{Key: "cloud-provider", Value: inp.CloudProvider},
+		telemetry.AttributeKV{Key: "cloud-provider-credential-identifier", Value: inp.CloudProviderCredentialIdentifier},
+	)
 
-	clusters, err := h.Repo().Cluster().ListClustersByProjectID(projectID)
+	var datastoreCluster *models.Cluster
+	clusters, err := h.Repo().Cluster().ListClustersByProjectID(inp.ProjectID)
 	if err != nil {
-		return nil, telemetry.Error(ctx, span, err, "unable to get project clusters")
+		return telemetry.Error(ctx, span, err, "unable to get project clusters")
 	}
 
 	for _, cluster := range clusters {
-		helmAgent, err := h.GetHelmAgent(ctx, r, cluster, "ack-system")
-		if err != nil {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
-			return nil, telemetry.Error(ctx, span, err, "unable to get helm client for cluster")
+		if cluster.CloudProvider == inp.CloudProvider && cluster.CloudProviderCredentialIdentifier == inp.CloudProviderCredentialIdentifier {
+			datastoreCluster = cluster
 		}
+	}
 
-		_, err = helmAgent.GetRelease(ctx, datastoreName, 0, false)
-		if err == nil {
-			return cluster, nil
-		}
+	if datastoreCluster == nil {
+		return telemetry.Error(ctx, span, nil, "unable to find datastore cluster")
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: datastoreCluster.ID})
+
+	helmAgent, err := h.GetHelmAgent(ctx, inp.Request, datastoreCluster, release.Namespace_ACKSystem)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to get helm client for cluster")
+	}
+
+	_, err = helmAgent.UninstallChart(ctx, inp.Name)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "unable to uninstall chart")
 	}
 
-	return nil, telemetry.Error(ctx, span, nil, "unable to find datastore on any associated cluster")
+	return nil
 }

+ 126 - 0
api/server/handlers/datastore/get.go

@@ -0,0 +1,126 @@
+package datastore
+
+import (
+	"net/http"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
+	"github.com/google/uuid"
+	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/handlers/cloud_provider"
+	"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/telemetry"
+)
+
+// GetDatastoreResponse describes the list datastores response body
+type GetDatastoreResponse struct {
+	// Datastore is the datastore that has been retrieved
+	Datastore Datastore `json:"datastore"`
+}
+
+// GetDatastoreHandler is a struct for retrieving a datastore
+type GetDatastoreHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewGetDatastoreHandler returns a GetDatastoreHandler
+func NewGetDatastoreHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetDatastoreHandler {
+	return &GetDatastoreHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+const (
+	// SupportedDatastoreCloudProvider_AWS is the AWS cloud provider
+	SupportedDatastoreCloudProvider_AWS string = "AWS"
+)
+
+// ServeHTTP retrieves the datastore in the given project
+func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-datastore")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	resp := GetDatastoreResponse{}
+
+	datastoreName, reqErr := requestutils.GetURLParamString(r, types.URLParamDatastoreName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing datastore name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-name", Value: datastoreName})
+
+	datastoreRecord, err := c.Repo().Datastore().GetByProjectIDAndName(ctx, project.ID, datastoreName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "datastore record not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if datastoreRecord == nil || datastoreRecord.ID == uuid.Nil {
+		err = telemetry.Error(ctx, span, nil, "datastore record does not exist")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+
+	if datastoreRecord.CloudProvider != SupportedDatastoreCloudProvider_AWS {
+		err = telemetry.Error(ctx, span, nil, "unsupported datastore cloud provider")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	awsArn, err := arn.Parse(datastoreRecord.CloudProviderCredentialIdentifier)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error parsing aws account id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	datastores, err := Datastores(ctx, DatastoresInput{
+		ProjectID: project.ID,
+		CloudProvider: cloud_provider.CloudProvider{
+			AccountID: awsArn.AccountID,
+			Type:      porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS,
+		},
+		Name:            datastoreName,
+		IncludeEnvGroup: true,
+		IncludeMetadata: true,
+		CCPClient:       c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting datastore")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if len(datastores) == 0 {
+		err = telemetry.Error(ctx, span, nil, "datastore not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+	if len(datastores) > 1 {
+		err = telemetry.Error(ctx, span, nil, "unexpected number of datastores found matching name")
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-count", Value: len(datastores)})
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	datastore := datastores[0]
+	datastore.Type = datastoreRecord.Type
+	datastore.Engine = datastoreRecord.Engine
+
+	resp.Datastore = datastore
+
+	c.WriteResult(w, r, resp)
+}

+ 75 - 69
api/server/handlers/datastore/list.go

@@ -1,16 +1,18 @@
 package datastore
 
 import (
+	"context"
 	"net/http"
 
 	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
 	"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/telemetry"
@@ -35,17 +37,20 @@ type ListDatastoresRequest struct {
 // ListDatastoresResponse describes the list datastores response body
 type ListDatastoresResponse struct {
 	// Datastores is a list of datastore entries for the http response
-	Datastores []DatastoresResponseEntry `json:"datastores"`
+	Datastores []Datastore `json:"datastores"`
 }
 
-// DatastoresResponseEntry describes an outbound datastores response entry
-type DatastoresResponseEntry struct {
+// Datastore describes an outbound datastores response entry
+type Datastore struct {
 	// Name is the name of the datastore
 	Name string `json:"name"`
 
 	// Type is the type of the datastore
 	Type string `json:"type"`
 
+	// Engine is the engine of the datastore
+	Engine string `json:"engine,omitempty"`
+
 	// Env is the env group for the datastore
 	Env *porterv1.EnvGroup `json:"env,omitempty"`
 
@@ -56,7 +61,7 @@ type DatastoresResponseEntry struct {
 	Status string `json:"status,omitempty"`
 }
 
-// ListDatastoresHandler is a struct for handling datastore status requests
+// ListDatastoresHandler is a struct for listing all datastores for a given project
 type ListDatastoresHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -74,96 +79,97 @@ func NewListDatastoresHandler(
 	}
 }
 
-// ServeHTTP returns a list of datastores associated with the specified project/cloud-provider
+// ServeHTTP returns a list of datastores associated with the specified project
 func (h *ListDatastoresHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-datastore-list")
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-datastores")
 	defer span.End()
 
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	request := &ListDatastoresRequest{}
-	if ok := h.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
+	resp := ListDatastoresResponse{}
+	datastoreList := []Datastore{}
 
-	cloudProviderType, err := requestutils.GetURLParamString(r, types.URLParamCloudProviderType)
+	datastores, err := h.Repo().Datastore().ListByProjectID(ctx, project.ID)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error parsing cloud provider type")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		err := telemetry.Error(ctx, span, err, "error getting datastores")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cloud-provider-type", Value: cloudProviderType})
 
-	cloudProviderID, err := requestutils.GetURLParamString(r, types.URLParamCloudProviderID)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error parsing cloud provider id")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cloud-provider-id", Value: cloudProviderID})
-
-	datastoreType := porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED
-	switch request.Type {
-	case "elasticache-redis":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_ELASTICACHE_REDIS
-	case "rds-postgresql":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_POSTGRESQL
-	case "rds-postgresql-aurora":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_AURORA_POSTGRESQL
-	case "":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED
-	default:
-		err := telemetry.Error(ctx, span, err, "invalid datastore type specified")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
+	for _, datastore := range datastores {
+		datastoreList = append(datastoreList, Datastore{
+			Name:   datastore.Name,
+			Type:   datastore.Type,
+			Engine: datastore.Engine,
+		})
 	}
 
+	resp.Datastores = datastoreList
+
+	h.WriteResult(w, r, resp)
+}
+
+// DatastoresInput is the input to the Datastores function
+type DatastoresInput struct {
+	ProjectID       uint
+	CloudProvider   cloud_provider.CloudProvider
+	Name            string
+	Type            porterv1.EnumDatastore
+	IncludeEnvGroup bool
+	IncludeMetadata bool
+
+	CCPClient porterv1connect.ClusterControlPlaneServiceClient
+}
+
+// Datastores returns a list of datastores associated with the specified project/cloud-provider
+func Datastores(ctx context.Context, inp DatastoresInput) ([]Datastore, error) {
+	ctx, span := telemetry.NewSpan(ctx, "datastores-for-cloud-provider")
+	defer span.End()
+
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "datastore-name", Value: request.Name},
-		telemetry.AttributeKV{Key: "datastore-type", Value: request.Type},
-		telemetry.AttributeKV{Key: "include-env-group", Value: request.IncludeEnvGroup},
-		telemetry.AttributeKV{Key: "include-metadata", Value: request.IncludeMetadata},
+		telemetry.AttributeKV{Key: "datastore-name", Value: inp.Name},
+		telemetry.AttributeKV{Key: "datastore-type", Value: int(inp.Type)},
+		telemetry.AttributeKV{Key: "include-env-group", Value: inp.IncludeEnvGroup},
+		telemetry.AttributeKV{Key: "include-metadata", Value: inp.IncludeMetadata},
+		telemetry.AttributeKV{Key: "cloud-provider-type", Value: int(inp.CloudProvider.Type)},
+		telemetry.AttributeKV{Key: "cloud-provider-id", Value: inp.CloudProvider.AccountID},
+		telemetry.AttributeKV{Key: "project-id", Value: inp.ProjectID},
 	)
 
-	var cloudProvider porterv1.EnumCloudProvider
-	switch cloudProviderType {
-	case "aws":
-		cloudProvider = porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
-	default:
-		err := telemetry.Error(ctx, span, nil, "unsupported cloud provider")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
+	datastores := []Datastore{}
+
+	if inp.ProjectID == 0 {
+		return datastores, telemetry.Error(ctx, span, nil, "project id must be specified")
+	}
+	if inp.CloudProvider.Type == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_UNSPECIFIED {
+		return datastores, telemetry.Error(ctx, span, nil, "cloud provider type must be specified")
+	}
+	if inp.CloudProvider.AccountID == "" {
+		return datastores, telemetry.Error(ctx, span, nil, "cloud provider account id must be specified")
 	}
 
 	message := porterv1.ListDatastoresRequest{
-		ProjectId:              int64(project.ID),
-		CloudProvider:          cloudProvider,
-		CloudProviderAccountId: cloudProviderID,
-		Name:                   request.Name,
-		IncludeEnvGroup:        request.IncludeEnvGroup,
-		IncludeMetadata:        request.IncludeMetadata,
+		ProjectId:              int64(inp.ProjectID),
+		CloudProvider:          inp.CloudProvider.Type,
+		CloudProviderAccountId: inp.CloudProvider.AccountID,
+		Name:                   inp.Name,
+		IncludeEnvGroup:        inp.IncludeEnvGroup,
+		IncludeMetadata:        inp.IncludeMetadata,
 	}
-	if datastoreType != porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED {
-		message.Type = &datastoreType
+	if inp.Type != porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED {
+		message.Type = &inp.Type
 	}
 	req := connect.NewRequest(&message)
-	resp, ccpErr := h.Config().ClusterControlPlaneClient.ListDatastores(ctx, req)
+	resp, ccpErr := inp.CCPClient.ListDatastores(ctx, req)
 	if ccpErr != nil {
-		err := telemetry.Error(ctx, span, ccpErr, "error listing datastores from ccp")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+		return datastores, telemetry.Error(ctx, span, ccpErr, "error listing datastores from ccp")
 	}
 	if resp.Msg == nil {
-		err := telemetry.Error(ctx, span, err, "missing response message from ccp")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+		return datastores, telemetry.Error(ctx, span, nil, "missing response message from ccp")
 	}
 
-	response := ListDatastoresResponse{
-		Datastores: []DatastoresResponseEntry{},
-	}
 	for _, datastore := range resp.Msg.Datastores {
-		response.Datastores = append(response.Datastores, DatastoresResponseEntry{
+		datastores = append(datastores, Datastore{
 			Name:     datastore.Name,
 			Type:     datastore.Type.Enum().String(),
 			Metadata: datastore.Metadata,
@@ -171,5 +177,5 @@ func (h *ListDatastoresHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		})
 	}
 
-	h.WriteResult(w, r, response)
+	return datastores, nil
 }

+ 3 - 3
api/server/handlers/datastore/update.go

@@ -72,7 +72,7 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		telemetry.AttributeKV{Key: "engine", Value: request.Engine},
 	)
 
-	record, err := datastore.CreateOrGetDatastoreRecord(ctx, datastore.CreateOrGetDatastoreRecordInput{
+	record, err := datastore.CreateOrGetRecord(ctx, datastore.CreateOrGetRecordInput{
 		ProjectID:           project.ID,
 		ClusterID:           cluster.ID,
 		Name:                request.Name,
@@ -193,7 +193,7 @@ func (h *UpdateDatastoreHandler) getVPCConfig(ctx context.Context, templateName
 	)
 
 	vpcConfig := map[string]any{}
-	if cluster.CloudProvider != "AWS" {
+	if cluster.CloudProvider != SupportedDatastoreCloudProvider_AWS {
 		return vpcConfig, nil
 	}
 
@@ -278,7 +278,7 @@ func (h *UpdateDatastoreHandler) performAddonPreinstall(ctx context.Context, r *
 		telemetry.AttributeKV{Key: "cloud-provider", Value: cluster.CloudProvider},
 	)
 
-	if cluster.CloudProvider != "AWS" {
+	if cluster.CloudProvider != SupportedDatastoreCloudProvider_AWS {
 		return nil
 	}
 

+ 1 - 146
api/server/handlers/release/create_addon.go

@@ -4,10 +4,7 @@ import (
 	"context"
 	"fmt"
 	"net/http"
-	"strings"
 
-	"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"
@@ -17,12 +14,10 @@ import (
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/loader"
-	"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/telemetry"
 	"github.com/stefanmcshane/helm/pkg/chart"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
 // Namespace_EnvironmentGroups is the base namespace for storing all environment groups.
@@ -108,27 +103,11 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	vpcConfig, err := c.getVPCConfig(ctx, request, proj, cluster)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error retrieving vpc config")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	if err := c.performAddonPreinstall(ctx, r, request.TemplateName, cluster); err != nil {
-		err = telemetry.Error(ctx, span, err, "error performing addon preinstall")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	values := request.Values
-	values["vpcConfig"] = vpcConfig
-
 	conf := &helm.InstallChartConfig{
 		Chart:      chart,
 		Name:       request.Name,
 		Namespace:  namespace,
-		Values:     values,
+		Values:     request.Values,
 		Cluster:    cluster,
 		Repo:       c.Repo(),
 		Registries: registries,
@@ -198,127 +177,3 @@ func LoadChart(ctx context.Context, config *config.Config, opts *LoadAddonChartO
 
 	return nil, fmt.Errorf("chart repo not found")
 }
-
-func (c *CreateAddonHandler) performAddonPreinstall(ctx context.Context, r *http.Request, templateName string, cluster *models.Cluster) error {
-	ctx, span := telemetry.NewSpan(ctx, "addon-preinstall")
-	defer span.End()
-
-	awsTemplates := map[string][]string{
-		"elasticache-redis":     {"ack-chart-ec2", "ack-chart-elasticache"},
-		"rds-postgresql":        {"ack-chart-ec2", "ack-chart-rds"},
-		"rds-postgresql-aurora": {"ack-chart-ec2", "ack-chart-rds"},
-	}
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "template-name", Value: templateName},
-		telemetry.AttributeKV{Key: "cloud-provider", Value: cluster.CloudProvider},
-	)
-
-	if cluster.CloudProvider != "AWS" {
-		return nil
-	}
-
-	if _, ok := awsTemplates[templateName]; !ok {
-		return nil
-	}
-
-	agent, err := c.GetAgent(r, cluster, "")
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "failed to get k8s agent")
-	}
-
-	if _, err = agent.GetNamespace(Namespace_EnvironmentGroups); err != nil {
-		if _, err := agent.CreateNamespace(Namespace_EnvironmentGroups, map[string]string{}); err != nil {
-			return telemetry.Error(ctx, span, err, "failed creating porter-env-group namespace")
-		}
-	}
-
-	for _, chart := range awsTemplates[templateName] {
-		if err := c.scaleAckChartDeployment(ctx, chart, agent); err != nil {
-			return telemetry.Error(ctx, span, err, "failed scaling ack chart deployment")
-		}
-	}
-
-	return nil
-}
-
-func (c *CreateAddonHandler) scaleAckChartDeployment(ctx context.Context, chart string, agent *kubernetes.Agent) error {
-	ctx, span := telemetry.NewSpan(ctx, "scale-ack-chart")
-	defer span.End()
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "namespace", Value: Namespace_ACKSystem},
-		telemetry.AttributeKV{Key: "chart-name", Value: chart},
-	)
-
-	scale, err := agent.Clientset.AppsV1().Deployments(Namespace_ACKSystem).GetScale(ctx, chart, metav1.GetOptions{})
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "failed getting deployment")
-	}
-	if scale.Spec.Replicas > 0 {
-		return nil
-	}
-
-	scale.Spec.Replicas = 1
-	if _, err := agent.Clientset.AppsV1().Deployments(Namespace_ACKSystem).UpdateScale(ctx, chart, scale, metav1.UpdateOptions{}); err != nil {
-		return telemetry.Error(ctx, span, err, "failed scaling deployment up")
-	}
-
-	return nil
-}
-
-func (c *CreateAddonHandler) getVPCConfig(ctx context.Context, request *types.CreateAddonRequest, project *models.Project, cluster *models.Cluster) (map[string]any, error) {
-	ctx, span := telemetry.NewSpan(ctx, "get-vpc-config")
-	defer span.End()
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "cloud-provider", Value: cluster.CloudProvider},
-		telemetry.AttributeKV{Key: "template-name", Value: request.TemplateName},
-	)
-
-	vpcConfig := map[string]any{}
-	if cluster.CloudProvider != "AWS" {
-		return vpcConfig, nil
-	}
-
-	awsTemplates := map[string]string{
-		"elasticache-redis":     "elasticache",
-		"rds-postgresql":        "rds",
-		"rds-postgresql-aurora": "rds",
-	}
-
-	serviceType, ok := awsTemplates[request.TemplateName]
-	if !ok {
-		return vpcConfig, nil
-	}
-
-	req := connect.NewRequest(&porterv1.SharedNetworkSettingsRequest{
-		ProjectId:   int64(project.ID),
-		ClusterId:   int64(cluster.ID),
-		ServiceType: serviceType,
-	})
-
-	resp, err := c.Config().ClusterControlPlaneClient.SharedNetworkSettings(ctx, req)
-	if err != nil {
-		return vpcConfig, telemetry.Error(ctx, span, err, "error fetching cluster network settings from ccp")
-	}
-
-	vpcConfig["cidrBlock"] = resp.Msg.CidrRange
-	vpcConfig["subnetIDs"] = resp.Msg.SubnetIds
-	switch resp.Msg.CloudProvider {
-	case *porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS.Enum():
-		vpcConfig["awsRegion"] = resp.Msg.Region
-		vpcConfig["vpcID"] = resp.Msg.GetEksCloudProviderNetwork().Id
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "aws-region", Value: resp.Msg.Region},
-			telemetry.AttributeKV{Key: "vpc-id", Value: resp.Msg.GetEksCloudProviderNetwork().Id},
-		)
-	}
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "cidr-block", Value: resp.Msg.CidrRange},
-		telemetry.AttributeKV{Key: "subnet-ids", Value: strings.Join(resp.Msg.SubnetIds, ",")},
-	)
-
-	return vpcConfig, nil
-}

+ 0 - 87
api/server/router/datastore.go

@@ -1,10 +1,7 @@
 package router
 
 import (
-	"fmt"
-
 	"github.com/go-chi/chi/v5"
-	"github.com/porter-dev/porter/api/server/handlers/datastore"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -58,89 +55,5 @@ func getDatastoreRoutes(
 	}
 	routes := make([]*router.Route, 0)
 
-	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewListDatastoresHandler
-	listEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-		},
-	)
-
-	listHandler := datastore.NewListDatastoresHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: listEndpoint,
-		Handler:  listHandler,
-		Router:   r,
-	})
-
-	// DELETE /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewDeleteDatastoreHandler
-	deleteEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-		},
-	)
-
-	deleteHandler := datastore.NewDeleteDatastoreHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: deleteEndpoint,
-		Handler:  deleteHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores/{datastore_type}/{datastore_name} -> cloud_provider.NewListDatastoresHandler
-	getEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores/{%s}/{%s}", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID, types.URLParamDatastoreType, types.URLParamDatastoreName),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-		},
-	)
-
-	getHandler := datastore.NewListDatastoresHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: getEndpoint,
-		Handler:  getHandler,
-		Router:   r,
-	})
-
 	return routes, newPath
 }

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

@@ -8,6 +8,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/api_token"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/api/server/handlers/datastore"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/handlers/infra"
@@ -470,6 +471,90 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/datastores -> datastore.NewListAllDatastoresForProjectHandler
+	listDatastoresEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/datastores",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listDatastoresHandler := datastore.NewListDatastoresHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listDatastoresEndpoint,
+		Handler:  listDatastoresHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/datastores -> datastore.NewGetDatastoreHandler
+	getDatastoreEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/datastores/{%s}", relPath, types.URLParamDatastoreName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getDatastoreHandler := datastore.NewGetDatastoreHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getDatastoreEndpoint,
+		Handler:  getDatastoreHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/datastores/{datastore_name} -> cloud_provider.NewDeleteDatastoreHandler
+	deleteDatastoreEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/datastores/{%s}", relPath, types.URLParamDatastoreName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	deleteDatastoreHandler := datastore.NewDeleteDatastoreHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteDatastoreEndpoint,
+		Handler:  deleteDatastoreHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/roles -> project.NewRoleUpdateHandler
 	updateRoleEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 93 - 12
dashboard/src/main/home/database-dashboard/forms/types.ts → dashboard/src/lib/databases/types.ts

@@ -1,21 +1,101 @@
 import { z } from "zod";
 
-export type AuroraPostgresFormValues = {
-  name: string;
-  databaseName: string;
-  masterUsername: string;
-  masterUserPassword: string;
-  allocatedStorage: number;
-  instanceClass: string;
+export const datastoreEnvValidator = z.object({
+  name: z.string(),
+  linked_applications: z.string().array().default([]),
+  secret_variables: z.record(z.string()).default({}),
+  variables: z.record(z.string()).default({}),
+  version: z.number(),
+});
+
+export type DatastoreEnvWithSource = z.infer<typeof datastoreEnvValidator>;
+
+export const datastoreMetadataValidator = z.object({
+  name: z.string(),
+  value: z.string().default(""),
+});
+
+export type DatastoreMetadataWithSource = z.infer<
+  typeof datastoreMetadataValidator
+>;
+
+export const datastoreValidator = z.object({
+  name: z.string(),
+  type: z.string(),
+  engine: z.string(),
+  status: z.string().default(""),
+  metadata: datastoreMetadataValidator.array().default([]),
+  env: datastoreEnvValidator.optional(),
+  connection_string: z.string().default(""),
+});
+
+export type ClientDatastore = z.infer<typeof datastoreValidator>;
+
+export const datastoreListResponseValidator = z.object({
+  datastores: datastoreValidator.array(),
+});
+
+export const cloudProviderValidator = z.object({
+  cloud_provider_id: z.string(),
+  project_id: z.number(),
+});
+
+export type CloudProviderWithSource = z.infer<typeof cloudProviderValidator>;
+
+export const cloudProviderListResponseValidator = z.object({
+  accounts: cloudProviderValidator.array(),
+});
+
+export const cloudProviderDatastoreSchema = z.object({
+  project_id: z.number(),
+  cloud_provider_name: z.string(),
+  cloud_provider_id: z.string(),
+  datastore: datastoreValidator,
+});
+
+export type CloudProviderDatastore = z.infer<
+  typeof cloudProviderDatastoreSchema
+>;
+
+export type DatabaseEngine =
+  | typeof DATABASE_ENGINE_POSTGRES
+  | typeof DATABASE_ENGINE_AURORA_POSTGRES
+  | typeof DATABASE_ENGINE_REDIS
+  | typeof DATABASE_ENGINE_MEMCACHED;
+export const DATABASE_ENGINE_POSTGRES = {
+  name: "POSTGRES" as const,
+  displayName: "PostgreSQL",
+};
+export const DATABASE_ENGINE_AURORA_POSTGRES = {
+  name: "AURORA-POSTGRES" as const,
+  displayName: "Aurora PostgreSQL",
+};
+export const DATABASE_ENGINE_REDIS = {
+  name: "REDIS" as const,
+  displayName: "Redis",
 };
+export const DATABASE_ENGINE_MEMCACHED = {
+  name: "MEMCACHED" as const,
+  displayName: "Memcached",
+};
+
+export type DatabaseType =
+  | typeof DATABASE_TYPE_RDS
+  | typeof DATABASE_TYPE_ELASTICACHE;
+export const DATABASE_TYPE_RDS = "RDS" as const;
+export const DATABASE_TYPE_ELASTICACHE = "ELASTICACHE" as const;
 
-export type ElasticacheRedisFormValues = {
+export type DatabaseTemplate = {
+  type: DatabaseType;
+  engine: DatabaseEngine;
+  icon: string;
   name: string;
-  databaseName: string;
-  masterUsername: string;
-  masterUserPassword: string;
-  instanceClass: string;
+  description: string;
+  disabled: boolean;
+  instanceTiers: ResourceOption[];
+  formTitle: string;
 };
+
 const instanceTierValidator = z.enum([
   "unspecified",
   "db.t4g.small",
@@ -110,6 +190,7 @@ export const dbFormValidator = z.object({
     auroraPostgresConfigValidator,
     elasticacheRedisConfigValidator,
   ]),
+  clusterId: z.number(),
 });
 export type DbFormData = z.infer<typeof dbFormValidator>;
 

+ 45 - 0
dashboard/src/lib/hooks/useClusterList.ts

@@ -0,0 +1,45 @@
+import { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+export const clusterValidator = z.object({
+  id: z.number(),
+  name: z.string(),
+  vanity_name: z.string(),
+});
+export type Cluster = z.infer<typeof clusterValidator>;
+
+type TUseClusterList = {
+  clusters: Array<z.infer<typeof clusterValidator>>;
+  isLoading: boolean;
+};
+export const useClusterList = (): TUseClusterList => {
+  const { currentProject } = useContext(Context);
+
+  const clusterReq = useQuery(
+    ["getClusters", currentProject?.id],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const res = await api.getClusters(
+        "<token>",
+        {},
+        { id: currentProject.id }
+      );
+      const parsed = await z.array(clusterValidator).parseAsync(res.data);
+      return parsed;
+    },
+    {
+      enabled: !!currentProject && currentProject.id !== -1,
+    }
+  );
+
+  return {
+    clusters: clusterReq.data ?? [],
+    isLoading: clusterReq.isLoading,
+  };
+};

+ 0 - 85
dashboard/src/lib/hooks/useDatabase.ts

@@ -1,85 +0,0 @@
-import { useCallback, useContext } from "react";
-import { match } from "ts-pattern";
-
-import { type DbFormData } from "main/home/database-dashboard/forms/types";
-
-import api from "shared/api";
-import { Context } from "shared/Context";
-
-type DatabaseHook = {
-  createDatabase: (values: DbFormData) => Promise<void>;
-};
-const clientDbToValues = (
-  values: DbFormData
-): { values: object; templateName: string } => {
-  return match(values)
-    .with({ config: { type: "rds-postgres" } }, (values) => ({
-      values: {
-        config: {
-          name: values.name,
-          databaseName: values.config.databaseName,
-          masterUsername: values.config.masterUsername,
-          masterUserPassword: values.config.masterUserPassword,
-          allocatedStorage: values.config.allocatedStorageGigabytes,
-          instanceClass: values.config.instanceClass,
-        },
-      },
-      templateName: "rds-postgresql",
-    }))
-    .with({ config: { type: "rds-postgresql-aurora" } }, (values) => ({
-      values: {
-        config: {
-          name: values.name,
-          databaseName: values.config.databaseName,
-          masterUsername: values.config.masterUsername,
-          masterUserPassword: values.config.masterUserPassword,
-          allocatedStorage: values.config.allocatedStorageGigabytes,
-          instanceClass: values.config.instanceClass,
-        },
-      },
-      templateName: "rds-postgresql-aurora",
-    }))
-    .with({ config: { type: "elasticache-redis" } }, (values) => ({
-      values: {
-        config: {
-          name: values.name,
-          databaseName: values.config.databaseName,
-          masterUsername: values.config.masterUsername,
-          masterUserPassword: values.config.masterUserPassword,
-          instanceClass: values.config.instanceClass,
-        },
-      },
-      templateName: "elasticache-redis",
-    }))
-    .exhaustive();
-};
-
-export const useDatabase = (): DatabaseHook => {
-  const { capabilities, currentProject, currentCluster } = useContext(Context);
-
-  const createDatabase = useCallback(
-    async (data: DbFormData): Promise<void> => {
-      const { values, templateName } = clientDbToValues(data);
-      const name = data.name;
-
-      await api.deployAddon(
-        "<token>",
-        {
-          template_name: templateName,
-          template_version: "latest",
-          values,
-          name,
-        },
-        {
-          id: currentProject?.id || -1,
-          cluster_id: currentCluster?.id || -1,
-          namespace: "ack-system",
-          repo_url: capabilities?.default_addon_helm_repo_url,
-        }
-      );
-    },
-    [currentProject, currentCluster, capabilities]
-  );
-
-  return { createDatabase };
-};

+ 49 - 0
dashboard/src/lib/hooks/useDatabaseList.ts

@@ -0,0 +1,49 @@
+import { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+
+import {
+  datastoreListResponseValidator,
+  type ClientDatastore,
+} from "lib/databases/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type DatabaseListType = {
+  datastores: ClientDatastore[];
+  isLoading: boolean;
+};
+export const useDatabaseList = (): DatabaseListType => {
+  const { currentProject } = useContext(Context);
+
+  const { data: datastores, isLoading: isLoadingDatastores } = useQuery(
+    ["listDatastores"],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+
+      const response = await api.listDatastores(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      );
+
+      const parsed = await datastoreListResponseValidator.parseAsync(
+        response.data
+      );
+      return parsed.datastores;
+    },
+    {
+      enabled: !!currentProject?.id && currentProject.id !== -1,
+      refetchOnWindowFocus: false,
+    }
+  );
+
+  return {
+    datastores: datastores ?? [],
+    isLoading: isLoadingDatastores,
+  };
+};

+ 130 - 0
dashboard/src/lib/hooks/useDatabaseMethods.ts

@@ -0,0 +1,130 @@
+import { useCallback, useContext } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { match } from "ts-pattern";
+
+import { type DbFormData } from "lib/databases/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type DatabaseHook = {
+  create: (values: DbFormData) => Promise<void>;
+  deleteDatastore: (name: string) => Promise<void>;
+};
+type CreateDatastoreInput = {
+  name: string;
+  type: "RDS" | "ELASTICACHE";
+  engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
+  values: object;
+};
+const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
+  return match(values)
+    .with(
+      { config: { type: "rds-postgres" } },
+      (values): CreateDatastoreInput => ({
+        name: values.name,
+        values: {
+          config: {
+            name: values.name,
+            databaseName: values.config.databaseName,
+            masterUsername: values.config.masterUsername,
+            masterUserPassword: values.config.masterUserPassword,
+            allocatedStorage: values.config.allocatedStorageGigabytes,
+            instanceClass: values.config.instanceClass,
+          },
+        },
+        type: "RDS",
+        engine: "POSTGRES",
+      })
+    )
+    .with(
+      { config: { type: "rds-postgresql-aurora" } },
+      (values): CreateDatastoreInput => ({
+        name: values.name,
+        values: {
+          config: {
+            name: values.name,
+            databaseName: values.config.databaseName,
+            masterUsername: values.config.masterUsername,
+            masterUserPassword: values.config.masterUserPassword,
+            allocatedStorage: values.config.allocatedStorageGigabytes,
+            instanceClass: values.config.instanceClass,
+          },
+        },
+        type: "RDS",
+        engine: "AURORA-POSTGRES",
+      })
+    )
+    .with(
+      { config: { type: "elasticache-redis" } },
+      (values): CreateDatastoreInput => ({
+        name: values.name,
+        values: {
+          config: {
+            name: values.name,
+            databaseName: values.config.databaseName,
+            masterUsername: values.config.masterUsername,
+            masterUserPassword: values.config.masterUserPassword,
+            instanceClass: values.config.instanceClass,
+          },
+        },
+        type: "ELASTICACHE",
+        engine: "REDIS",
+      })
+    )
+    .exhaustive();
+};
+
+export const useDatabaseMethods = (): DatabaseHook => {
+  const { currentProject } = useContext(Context);
+
+  const queryClient = useQueryClient();
+
+  const create = useCallback(
+    async (data: DbFormData): Promise<void> => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const createDatastoreInput = clientDbToCreateInput(data);
+
+      await api.updateDatastore(
+        "<token>",
+        {
+          name: createDatastoreInput.name,
+          type: createDatastoreInput.type,
+          engine: createDatastoreInput.engine,
+          values: createDatastoreInput.values,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: data.clusterId,
+        }
+      );
+
+      await queryClient.invalidateQueries({ queryKey: ["listDatastores"] });
+    },
+    [currentProject]
+  );
+
+  const deleteDatastore = useCallback(
+    async (name: string): Promise<void> => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+
+      await api.deleteDatastore(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          datastore_name: name,
+        }
+      );
+
+      await queryClient.invalidateQueries({ queryKey: ["listDatastores"] });
+    },
+    [currentProject]
+  );
+
+  return { create, deleteDatastore };
+};

+ 3 - 4
dashboard/src/main/home/Home.tsx

@@ -465,16 +465,15 @@ const Home: React.FC<Props> = (props) => {
                 <Route path="/databases/new">
                   <CreateDatabase />
                 </Route>
-                <Route path="/databases/:projectId/:cloudProviderName/:cloudProviderId/:datastoreName/:tab">
+                <Route path="/databases/:datastoreName/:tab">
                   <DatabaseView />
                 </Route>
-                <Route path="/databases/:projectId/:cloudProviderName/:cloudProviderId/:datastoreName">
+                <Route path="/databases/:datastoreName">
                   <DatabaseView />
                 </Route>
                 <Route path="/databases">
-                  <DatabaseDashboard projectId={currentProject?.id} />
+                  <DatabaseDashboard />
                 </Route>
-
                 <Route path="/addons/new">
                   <NewAddOnFlow />
                 </Route>

+ 11 - 10
dashboard/src/main/home/database-dashboard/CreateDatabase.tsx

@@ -9,6 +9,14 @@ import Back from "components/porter/Back";
 import Spacer from "components/porter/Spacer";
 import Tag from "components/porter/Tag";
 import Text from "components/porter/Text";
+import {
+  DATABASE_ENGINE_AURORA_POSTGRES,
+  DATABASE_ENGINE_POSTGRES,
+  DATABASE_ENGINE_REDIS,
+  DATABASE_TYPE_ELASTICACHE,
+  DATABASE_TYPE_RDS,
+  type DatabaseTemplate,
+} from "lib/databases/types";
 
 import database from "assets/database.svg";
 
@@ -17,14 +25,6 @@ import { SUPPORTED_DATABASE_TEMPLATES } from "./constants";
 import DatabaseFormAuroraPostgres from "./forms/DatabaseFormAuroraPostgres";
 import DatabaseFormElasticacheRedis from "./forms/DatabaseFormElasticacheRedis";
 import DatabaseFormRDSPostgres from "./forms/DatabaseFormRDSPostgres";
-import {
-  DATABASE_ENGINE_POSTGRES,
-  DATABASE_ENGINE_REDIS,
-  DATABASE_TYPE_AURORA,
-  DATABASE_TYPE_ELASTICACHE,
-  DATABASE_TYPE_RDS,
-  type DatabaseTemplate,
-} from "./types";
 
 type Props = RouteComponentProps;
 const CreateDatabase: React.FC<Props> = ({ history, match: queryMatch }) => {
@@ -57,7 +57,7 @@ const CreateDatabase: React.FC<Props> = ({ history, match: queryMatch }) => {
           (t) => <DatabaseFormRDSPostgres template={t} />
         )
         .with(
-          { type: DATABASE_TYPE_AURORA, engine: DATABASE_ENGINE_POSTGRES },
+          { type: DATABASE_TYPE_RDS, engine: DATABASE_ENGINE_AURORA_POSTGRES },
           (t) => <DatabaseFormAuroraPostgres template={t} />
         )
         .with(
@@ -96,9 +96,10 @@ const CreateDatabase: React.FC<Props> = ({ history, match: queryMatch }) => {
                       <Spacer inline x={0.5} />
                       <TemplateTitle>{name}</TemplateTitle>
                       <Spacer inline x={0.5} />
-                      <Tag hoverable={false}>{engine.displayName}</Tag>
                     </TemplateHeader>
                     <Spacer y={0.5} />
+                    <Tag hoverable={false}>{engine.displayName}</Tag>
+                    <Spacer y={0.5} />
                     <TemplateDescription>{description}</TemplateDescription>
                     <Spacer y={0.5} />
                   </TemplateBlock>

+ 114 - 0
dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx

@@ -0,0 +1,114 @@
+import React, { createContext, useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+import styled from "styled-components";
+import { z } from "zod";
+
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { datastoreValidator, type ClientDatastore } from "lib/databases/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import notFound from "assets/not-found.png";
+
+type DatabaseContextType = {
+  datastore: ClientDatastore;
+  projectId: number;
+};
+
+const DatabaseContext = createContext<DatabaseContextType | null>(null);
+
+export const useDatabaseContext = (): DatabaseContextType => {
+  const ctx = React.useContext(DatabaseContext);
+  if (!ctx) {
+    throw new Error(
+      "useDatabaseContext must be used within a DatabaseContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type DatabaseContextProviderProps = {
+  datastoreName?: string;
+  children: JSX.Element;
+};
+export const DatabaseContextProvider: React.FC<
+  DatabaseContextProviderProps
+> = ({ datastoreName, children }) => {
+  const { currentProject } = useContext(Context);
+  const paramsExist =
+    !!datastoreName && !!currentProject && currentProject.id !== -1;
+  const { data: datastore, status } = useQuery(
+    ["getDatastore", datastoreName, currentProject?.id],
+    async () => {
+      if (!paramsExist) {
+        return;
+      }
+      const response = await api.getDatastore(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          datastore_name: datastoreName,
+        }
+      );
+
+      const results = await z
+        .object({ datastore: datastoreValidator })
+        .parseAsync(response.data);
+      return results.datastore;
+    },
+    {
+      enabled: paramsExist,
+      refetchInterval: 5000,
+      refetchOnWindowFocus: false,
+    }
+  );
+  if (status === "loading" || !paramsExist) {
+    return <Loading />;
+  }
+
+  if (status === "error" || !datastore) {
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No database matching &quot;{datastoreName}&quot; was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/databases">Return to dashboard</Link>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <DatabaseContext.Provider
+      value={{
+        datastore,
+        projectId: currentProject.id,
+      }}
+    >
+      {children}
+    </DatabaseContext.Provider>
+  );
+};
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;

+ 56 - 127
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -1,5 +1,4 @@
 import React, { useContext, useMemo, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
 import _ from "lodash";
 import { Link } from "react-router-dom";
 import styled from "styled-components";
@@ -13,11 +12,13 @@ import Fieldset from "components/porter/Fieldset";
 import PorterLink from "components/porter/Link";
 import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { type ClientDatastore } from "lib/databases/types";
+import { useDatabaseList } from "lib/hooks/useDatabaseList";
 
-import api from "shared/api";
 import { Context } from "shared/Context";
 import { search } from "shared/search";
 import database from "assets/database.svg";
@@ -27,110 +28,21 @@ import loading from "assets/loading.gif";
 import notFound from "assets/not-found.png";
 import healthy from "assets/status-healthy.png";
 
-import { getDatastoreIcon } from "./icons";
-import {
-  cloudProviderListResponseValidator,
-  datastoreListResponseValidator,
-  type CloudProviderDatastore,
-  type CloudProviderWithSource,
-} from "./types";
-import { datastoreField } from "./utils";
-
-type Props = {
-  projectId: number;
-};
+import { getTemplateEngineDisplayName, getTemplateIcon } from "./constants";
 
-const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
+const DatabaseDashboard: React.FC = () => {
   const { currentCluster } = useContext(Context);
 
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState<"grid" | "list">("grid");
 
-  const { data: cloudProviderResponse } = useQuery(
-    ["cloudProviders", projectId],
-    async () => {
-      const response = await api.getAwsCloudProviders(
-        "<token>",
-        {},
-        {
-          project_id: projectId,
-        }
-      );
-
-      const results = await cloudProviderListResponseValidator.parseAsync(
-        response.data
-      );
-      return results;
-    },
-    {
-      enabled: !!projectId,
-    }
-  );
-
-  const cloudProviders = cloudProviderResponse?.accounts;
-
-  const { data: datastores, isFetched: isLoaded } = useQuery(
-    [projectId],
-    async () => {
-      if (cloudProviders === undefined) {
-        return;
-      }
-
-      const results = await Promise.all(
-        cloudProviders.map(
-          async (
-            cloudProvider: CloudProviderWithSource
-          ): Promise<CloudProviderDatastore[]> => {
-            const response = await api.getDatastores(
-              "<token>",
-              {},
-              {
-                project_id: cloudProvider.project_id,
-                cloud_provider_name: "aws",
-                cloud_provider_id: cloudProvider.cloud_provider_id,
-                include_metadata: true,
-              }
-            );
-
-            const results = await datastoreListResponseValidator.parseAsync(
-              response.data
-            );
-            return results.datastores.map(
-              (datastore): CloudProviderDatastore => {
-                return {
-                  cloud_provider_name: "aws",
-                  cloud_provider_id: cloudProvider.cloud_provider_id,
-                  datastore,
-                  project_id: cloudProvider.project_id,
-                };
-              }
-            );
-          }
-        )
-      );
-
-      if (results.length === 0) {
-        return;
-      }
-
-      return results.flat(1);
-    },
-    {
-      enabled: !!cloudProviders,
-      refetchInterval: 10000,
-      refetchOnWindowFocus: false,
-    }
-  );
+  const { datastores, isLoading } = useDatabaseList();
 
   const filteredDatabases = useMemo(() => {
-    const filteredBySearch = search(
-      datastores === undefined ? [] : datastores,
-      searchValue,
-      {
-        keys: ["name"],
-        isCaseSensitive: false,
-      }
-    );
+    const filteredBySearch = search(datastores, searchValue, {
+      keys: ["name"],
+      isCaseSensitive: false,
+    });
 
     return _.sortBy(filteredBySearch, ["name"]);
   }, [datastores, searchValue]);
@@ -169,7 +81,7 @@ const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
       return <ClusterProvisioningPlaceholder />;
     }
 
-    if (datastores === undefined || !isLoaded) {
+    if (datastores === undefined || isLoading) {
       return <Loading offset="-150px" />;
     }
 
@@ -251,31 +163,39 @@ const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
               <Text color="helper">No matching databases were found.</Text>
             </Container>
           </Fieldset>
-        ) : !isLoaded ? (
+        ) : isLoading ? (
           <Loading offset="-150px" />
         ) : view === "grid" ? (
           <GridList>
             {(filteredDatabases ?? []).map(
-              (entry: CloudProviderDatastore, i: number) => {
+              (datastore: ClientDatastore, i: number) => {
+                const templateIcon = getTemplateIcon(
+                  datastore.type,
+                  datastore.engine
+                );
+                const templateDisplayName = getTemplateEngineDisplayName(
+                  datastore.engine
+                );
                 return (
-                  <Link
-                    to={`/databases/${entry.project_id}/${entry.cloud_provider_name}/${entry.cloud_provider_id}/${entry.datastore.name}/`}
-                    key={i}
-                  >
+                  <Link to={`/databases/${datastore.name}`} key={i}>
                     <Block>
-                      <Container row>
-                        <Icon src={getDatastoreIcon(entry.datastore.type)} />
-                        <Text size={14}>{entry.datastore.name}</Text>
-                        <Spacer inline x={2} />
+                      <Container row spaced>
+                        <Container row>
+                          <Icon src={templateIcon} />
+                          <Text size={14}>{datastore.name}</Text>
+                        </Container>
+                        <MidIcon src={healthy} height="16px" />
                       </Container>
-                      {renderStatusIcon(
-                        datastoreField(entry.datastore, "status")
+                      {templateDisplayName && (
+                        <>
+                          <Spacer y={1} />
+                          <Container row>
+                            <Tag hoverable={false}>
+                              <Text size={13}>{templateDisplayName}</Text>
+                            </Tag>
+                          </Container>
+                        </>
                       )}
-                      <Container row>
-                        <Text size={13} color="#ffffff44">
-                          {datastoreField(entry.datastore, "engine")}
-                        </Text>
-                      </Container>
                     </Block>
                   </Link>
                 );
@@ -285,23 +205,32 @@ const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
         ) : (
           <List>
             {(filteredDatabases ?? []).map(
-              (entry: CloudProviderDatastore, i: number) => {
+              (datastore: ClientDatastore, i: number) => {
+                const templateIcon = getTemplateIcon(
+                  datastore.type,
+                  datastore.engine
+                );
+                const templateDisplayName = getTemplateEngineDisplayName(
+                  datastore.engine
+                );
                 return (
-                  <Row
-                    to={`/databases/${entry.project_id}/${entry.cloud_provider_name}/${entry.cloud_provider_id}/${entry.datastore.name}/`}
-                    key={i}
-                  >
+                  <Row to={`/databases/${datastore.name}`} key={i}>
                     <Container row>
-                      <MidIcon src={getDatastoreIcon(entry.datastore.type)} />
-                      <Text size={14}>{entry.datastore.name}</Text>
+                      <MidIcon src={templateIcon} />
+                      <Text size={14}>{datastore.name}</Text>
                       <Spacer inline x={1} />
                       <MidIcon src={healthy} height="16px" />
                     </Container>
                     <Spacer height="15px" />
                     <Container row>
-                      <Text size={13} color="#ffffff44">
-                        {datastoreField(entry.datastore, "engine")}
-                      </Text>
+                      {templateDisplayName && (
+                        <>
+                          <Spacer inline x={0.5} />
+                          <Tag hoverable={false}>
+                            <Text size={13}>{templateDisplayName}</Text>
+                          </Tag>
+                        </>
+                      )}
                     </Container>
                   </Row>
                 );
@@ -365,7 +294,7 @@ const Icon = styled.img`
 `;
 
 const Block = styled.div`
-  height: 110px;
+  height: 120px;
   flex-direction: column;
   display: flex;
   justify-content: space-between;

+ 3 - 6
dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx

@@ -7,16 +7,13 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import TitleSection from "components/TitleSection";
 
+import { useDatabaseContext } from "./DatabaseContextProvider";
 import DatabaseHeaderItem from "./DatabaseHeaderItem";
 import { getDatastoreIcon } from "./icons";
-import { type DatastoreWithSource } from "./types";
 import { datastoreField } from "./utils";
 
-type Props = {
-  datastore: DatastoreWithSource;
-};
-
-const DatabaseHeader: React.FC<Props> = ({ datastore }) => {
+const DatabaseHeader: React.FC = () => {
+  const { datastore } = useDatabaseContext();
   return (
     <>
       <TitleSection icon={getDatastoreIcon(datastore.type)} iconWidth="33px">

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

@@ -4,11 +4,10 @@ import styled from "styled-components";
 import CopyToClipboard from "components/CopyToClipboard";
 import Container from "components/porter/Container";
 import Text from "components/porter/Text";
+import { type DatastoreMetadataWithSource } from "lib/databases/types";
 
 import copy from "assets/copy-left.svg";
 
-import { type DatastoreMetadataWithSource } from "./types";
-
 type DatabaseHeaderItemProps = {
   item: DatastoreMetadataWithSource;
 };

+ 12 - 22
dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx

@@ -1,14 +1,11 @@
-import React, {
-  useMemo
-} from "react";
+import React, { useMemo } from "react";
 import { useHistory } from "react-router";
 import { match } from "ts-pattern";
 
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 
-import { CloudProviderDatastore } from "./types";
-
+import { useDatabaseContext } from "./DatabaseContextProvider";
 import DatabaseEnvTab from "./tabs/DatabaseEnvTab";
 import MetricsTab from "./tabs/MetricsTab";
 import SettingsTab from "./tabs/SettingsTab";
@@ -16,7 +13,6 @@ import SettingsTab from "./tabs/SettingsTab";
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
 const validTabs = [
-
   "metrics",
   // "debug",
   "environment",
@@ -27,14 +23,14 @@ type ValidTab = (typeof validTabs)[number];
 
 type DbTabProps = {
   tabParam?: string;
-  item: CloudProviderDatastore;
 };
 
 // todo(ianedwards): refactor button to use more predictable state
 export type ButtonStatus = "" | "loading" | JSX.Element | "success";
 
-const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam, item }) => {
+const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
   const history = useHistory();
+  const { datastore } = useDatabaseContext();
 
   const currentTab = useMemo(() => {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
@@ -45,14 +41,10 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam, item }) => {
   }, [tabParam]);
 
   const tabs = useMemo(() => {
-    const base = [
-      { label: "Connection Info", value: "environment" },
-    ];
+    const base = [{ label: "Connection Info", value: "environment" }];
     base.push({ label: "Settings", value: "settings" });
     return base;
-  }, [
-
-  ]);
+  }, []);
 
   return (
     <>
@@ -61,15 +53,13 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam, item }) => {
         options={tabs}
         currentTab={currentTab}
         setCurrentTab={(tab) => {
-          history.push(
-            `/databases/${item.project_id}/${item.cloud_provider_name}/${item.cloud_provider_id}/${item.datastore.name}/${tab}`
-          );
-        }} /><Spacer y={1} />
+          history.push(`/databases/${datastore.name}/${tab}`);
+        }}
+      />
+      <Spacer y={1} />
       {match(currentTab)
-        .with("environment", () => (
-          <DatabaseEnvTab envData={item.datastore.env} />
-        ))
-        .with("settings", () => <SettingsTab item={item} />)
+        .with("environment", () => <DatabaseEnvTab envData={datastore.env} />)
+        .with("settings", () => <SettingsTab />)
         .with("metrics", () => <MetricsTab />)
 
         .otherwise(() => null)}

+ 11 - 58
dashboard/src/main/home/database-dashboard/DatabaseView.tsx

@@ -1,29 +1,24 @@
-import { useQuery } from "@tanstack/react-query";
 import React, { useMemo } from "react";
-import { useParams, withRouter, type RouteComponentProps } from "react-router";
+import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
 import { z } from "zod";
 
-import api from "shared/api";
-
-import Loading from "components/Loading";
 import Back from "components/porter/Back";
 import Spacer from "components/porter/Spacer";
 
+import { DatabaseContextProvider } from "./DatabaseContextProvider";
 import DatabaseHeader from "./DatabaseHeader";
 import DatabaseTabs from "./DatabaseTabs";
-import { CloudProviderDatastore, datastoreListResponseValidator } from "./types";
 
 type Props = RouteComponentProps;
 
 const DatabaseView: React.FC<Props> = ({ match }) => {
-  let { projectId, cloudProviderName, cloudProviderId, datastoreName } = useParams();
-
   const params = useMemo(() => {
     const { params } = match;
     const validParams = z
       .object({
         tab: z.string().optional(),
+        datastoreName: z.string().optional(),
       })
       .safeParse(params);
 
@@ -36,62 +31,20 @@ const DatabaseView: React.FC<Props> = ({ match }) => {
     return validParams.data;
   }, [match]);
 
-  const { data: item, status } = useQuery(
-    ["datastore", projectId, cloudProviderId, cloudProviderName, datastoreName],
-    async (): Promise<CloudProviderDatastore> => {
-      const response = await api.getDatastores(
-        "<token>",
-        {},
-        {
-          project_id: projectId,
-          cloud_provider_id: cloudProviderId,
-          cloud_provider_name: cloudProviderName,
-          datastore_name: datastoreName,
-          include_env_group: true,
-          include_metadata: true,
-        }
-      );
-
-      const results = await datastoreListResponseValidator.parseAsync(response.data);
-      if (results.datastores.length === 0) {
-        // TODO: fail the request
-        return {};
-      }
-
-      return results.datastores.map((datastore): CloudProviderDatastore => {
-        return {
-          cloud_provider_name: cloudProviderName,
-          cloud_provider_id: cloudProviderId,
-          datastore: datastore,
-          project_id: projectId,
-        }
-      })[0]
-    },
-    {
-      refetchInterval: 5000,
-      refetchOnWindowFocus: false,
-    }
-  );
-
   return (
-    <>
-      {(status === "loading" || item == null) ?
-        <Loading />
-        :
-        <StyledExpandedDB>
-          <Back to="/databases" />
-          <DatabaseHeader datastore={item.datastore} />
-          <Spacer y={1} />
-          <DatabaseTabs tabParam={params.tab} item={item} />
-        </StyledExpandedDB>
-      }
-    </>
+    <DatabaseContextProvider datastoreName={params.datastoreName}>
+      <StyledExpandedDB>
+        <Back to="/databases" />
+        <DatabaseHeader />
+        <Spacer y={1} />
+        <DatabaseTabs tabParam={params.tab} />
+      </StyledExpandedDB>
+    </DatabaseContextProvider>
   );
 };
 
 export default withRouter(DatabaseView);
 
-
 const StyledExpandedDB = styled.div`
   width: 100%;
   height: 100%;

+ 23 - 7
dashboard/src/main/home/database-dashboard/constants.ts

@@ -1,15 +1,31 @@
-import awsRDS from "assets/amazon-rds.png";
-import awsElastiCache from "assets/aws-elasticache.png";
-
 import {
+  DATABASE_ENGINE_AURORA_POSTGRES,
   DATABASE_ENGINE_MEMCACHED,
   DATABASE_ENGINE_POSTGRES,
   DATABASE_ENGINE_REDIS,
-  DATABASE_TYPE_AURORA,
   DATABASE_TYPE_ELASTICACHE,
   DATABASE_TYPE_RDS,
   type DatabaseTemplate,
-} from "./types";
+} from "lib/databases/types";
+
+import awsRDS from "assets/amazon-rds.png";
+import awsElastiCache from "assets/aws-elasticache.png";
+
+export const getTemplateIcon = (type: string, engine: string): string => {
+  const template = SUPPORTED_DATABASE_TEMPLATES.find(
+    (t) => t.type === type && t.engine.name === engine
+  );
+
+  return template ? template.icon : awsRDS;
+};
+
+export const getTemplateEngineDisplayName = (engine: string): string => {
+  const template = SUPPORTED_DATABASE_TEMPLATES.find(
+    (t) => t.engine.name === engine
+  );
+
+  return template ? template.engine.displayName : "";
+};
 
 export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
   Object.freeze({
@@ -47,8 +63,8 @@ export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
   }),
   Object.freeze({
     name: "Amazon Aurora",
-    type: DATABASE_TYPE_AURORA,
-    engine: DATABASE_ENGINE_POSTGRES,
+    type: DATABASE_TYPE_RDS,
+    engine: DATABASE_ENGINE_AURORA_POSTGRES,
     icon: awsRDS as string,
     description:
       "Amazon Aurora PostgreSQL is an ACID–compliant relational database engine that combines the speed, reliability, and manageability of Amazon Aurora with the simplicity and cost-effectiveness of open-source databases.",

+ 79 - 32
dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx

@@ -1,18 +1,24 @@
-import React, { useMemo, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import axios from "axios";
+import _ from "lodash";
 import { FormProvider, type UseFormReturn } from "react-hook-form";
 import { withRouter, type RouteComponentProps } from "react-router";
 import styled, { keyframes } from "styled-components";
 
 import Button from "components/porter/Button";
+import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
+import Selector from "components/porter/Selector";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
-import { useDatabase } from "lib/hooks/useDatabase";
+import { type DbFormData } from "lib/databases/types";
+import { useClusterList } from "lib/hooks/useClusterList";
+import { useDatabaseList } from "lib/hooks/useDatabaseList";
+import { useDatabaseMethods } from "lib/hooks/useDatabaseMethods";
 import { useIntercom } from "lib/hooks/useIntercom";
 
-import { type DbFormData } from "./types";
+import { Context } from "shared/Context";
 
 type Props = RouteComponentProps & {
   steps: React.ReactNode[];
@@ -26,32 +32,32 @@ const DatabaseForm: React.FC<Props> = ({
   form,
   history,
 }) => {
-  const [isCreating, setIsCreating] = useState<boolean>(false);
-  const { createDatabase } = useDatabase();
+  const [submitErrorMessage, setSubmitErrorMessage] = useState<string>("");
+  const { create: createDatabase } = useDatabaseMethods();
   const { showIntercomWithMessage } = useIntercom();
+  const { clusters } = useClusterList();
+  const { currentProject } = useContext(Context);
 
   const {
-    formState: { isSubmitting: isValidating, errors },
+    formState: { isSubmitting, errors, isValidating },
     handleSubmit,
-    setError,
-    clearErrors,
+    register,
+    setValue,
+    watch,
   } = form;
 
-  const submitBtnStatus = useMemo(() => {
-    if (isValidating || isCreating) {
-      return "loading";
-    }
-
-    if (Object.keys(errors).length) {
-      return <Error message={"Please address errors and resubmit."} />;
-    }
+  const { datastores: existingDatabases } = useDatabaseList();
 
-    return "";
-  }, [isValidating, errors]);
+  const chosenClusterId = watch("clusterId", 0);
 
   const onSubmit = handleSubmit(async (data) => {
-    setIsCreating(true);
-    clearErrors();
+    setSubmitErrorMessage("");
+    if (existingDatabases.some((db) => db.name === data.name)) {
+      setSubmitErrorMessage(
+        "A database with this name already exists. Please choose a different name."
+      );
+      return;
+    }
     try {
       await createDatabase(data);
       history.push(`/databases`);
@@ -60,39 +66,80 @@ const DatabaseForm: React.FC<Props> = ({
         axios.isAxiosError(err) && err.response?.data?.error
           ? err.response.data.error
           : "An error occurred while creating your database. Please try again.";
-      setError("root", { message: errorMessage });
+      setSubmitErrorMessage(errorMessage);
       showIntercomWithMessage({
         message: "I am having trouble creating a database.",
       });
-    } finally {
-      setIsCreating(false);
     }
   });
 
+  const submitButtonStatus = useMemo(() => {
+    if (isSubmitting || isValidating) {
+      return "loading";
+    }
+    if (submitErrorMessage) {
+      return <Error message={submitErrorMessage} />;
+    }
+    return undefined;
+  }, [isSubmitting, submitErrorMessage, isValidating]);
+
+  useEffect(() => {
+    if (clusters.length > 0) {
+      setValue("clusterId", clusters[0].id);
+    }
+  }, [JSON.stringify(clusters)]);
+
   return (
     <FormProvider {...form}>
       <form onSubmit={onSubmit}>
         <VerticalSteps
           currentStep={currentStep}
           steps={[
+            <>
+              <Text size={16}>Specify name</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Lowercase letters, numbers, and &quot;-&quot; only.
+              </Text>
+              <Spacer height="20px" />
+              <ControlledInput
+                placeholder="ex: academic-sophon-db"
+                type="text"
+                width="300px"
+                error={errors.name?.message}
+                {...register("name")}
+              />
+              {currentProject?.multi_cluster && (
+                <>
+                  <Spacer y={1} />
+                  <Selector<string>
+                    activeValue={chosenClusterId.toString()}
+                    width="300px"
+                    options={clusters.map((c) => ({
+                      value: c.id.toString(),
+                      label: c.vanity_name,
+                      key: c.id.toString(),
+                    }))}
+                    setActiveValue={(value: string) => {
+                      setValue("clusterId", parseInt(value));
+                    }}
+                    label={"Cluster"}
+                  />
+                </>
+              )}
+            </>,
             ...steps,
             <>
-              <Text size={16}>Create database instance</Text>
+              <Text size={16}>Create datastore instance</Text>
               <Spacer y={0.5} />
               <Button
                 type="submit"
-                status={submitBtnStatus}
+                status={submitButtonStatus}
                 loadingText={"Creating..."}
-                disabled={isCreating}
+                disabled={isSubmitting || isValidating}
               >
                 Create
               </Button>
-              {errors.root?.message && (
-                <AppearingErrorContainer>
-                  <Spacer y={0.5} />
-                  <Error message={errors.root.message} />
-                </AppearingErrorContainer>
-              )}
             </>,
           ]}
         />

+ 8 - 21
dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx

@@ -8,15 +8,19 @@ import { v4 as uuidv4 } from "uuid";
 import Back from "components/porter/Back";
 import ClickToCopy from "components/porter/ClickToCopy";
 import Container from "components/porter/Container";
-import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
 import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import {
+  dbFormValidator,
+  type DatabaseTemplate,
+  type DbFormData,
+  type ResourceOption,
+} from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
 import Resources from "../tabs/Resources";
-import { type DatabaseTemplate } from "../types";
 import DatabaseForm, {
   AppearingErrorContainer,
   Blur,
@@ -27,7 +31,6 @@ import DatabaseForm, {
   RevealButton,
   StyledConfigureTemplate,
 } from "./DatabaseForm";
-import { dbFormValidator, type DbFormData, type ResourceOption } from "./types";
 
 type Props = RouteComponentProps & {
   template: DatabaseTemplate;
@@ -53,7 +56,6 @@ const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
   const {
     setValue,
     formState: { errors },
-    register,
     watch,
   } = dbForm;
 
@@ -94,22 +96,7 @@ const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
           <DatabaseForm
             steps={[
               <>
-                <Text size={16}>Specify database name</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Lowercase letters, numbers, and &quot;-&quot; only.
-                </Text>
-                <Spacer height="20px" />
-                <ControlledInput
-                  placeholder="ex: academic-sophon-db"
-                  type="text"
-                  width="300px"
-                  error={errors.name?.message}
-                  {...register("name")}
-                />
-              </>,
-              <>
-                <Text size={16}>Specify database resources</Text>
+                <Text size={16}>Specify resources</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
                   Specify your database CPU, RAM, and storage.
@@ -137,7 +124,7 @@ const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
                 />
               </>,
               <>
-                <Text size={16}>View database credentials</Text>
+                <Text size={16}>View credentials</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
                   These credentials never leave your own cloud environment. You

+ 9 - 22
dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx

@@ -8,15 +8,19 @@ import { v4 as uuidv4 } from "uuid";
 import Back from "components/porter/Back";
 import ClickToCopy from "components/porter/ClickToCopy";
 import Container from "components/porter/Container";
-import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
 import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import {
+  dbFormValidator,
+  type DatabaseTemplate,
+  type DbFormData,
+  type ResourceOption,
+} from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
 import Resources from "../tabs/Resources";
-import { type DatabaseTemplate } from "../types";
 import DatabaseForm, {
   AppearingErrorContainer,
   Blur,
@@ -27,7 +31,6 @@ import DatabaseForm, {
   RevealButton,
   StyledConfigureTemplate,
 } from "./DatabaseForm";
-import { dbFormValidator, type DbFormData, type ResourceOption } from "./types";
 
 type Props = RouteComponentProps & {
   template: DatabaseTemplate;
@@ -56,7 +59,6 @@ const DatabaseFormElasticacheRedis: React.FC<Props> = ({
   const {
     setValue,
     formState: { errors },
-    register,
     watch,
   } = dbForm;
 
@@ -95,24 +97,9 @@ const DatabaseFormElasticacheRedis: React.FC<Props> = ({
           <DatabaseForm
             steps={[
               <>
-                <Text size={16}>Specify database name</Text>
+                <Text size={16}>Specify resources</Text>
                 <Spacer y={0.5} />
-                <Text color="helper">
-                  Lowercase letters, numbers, and &quot;-&quot; only.
-                </Text>
-                <Spacer height="20px" />
-                <ControlledInput
-                  placeholder="ex: academic-sophon-db"
-                  type="text"
-                  width="300px"
-                  error={errors.name?.message}
-                  {...register("name")}
-                />
-              </>,
-              <>
-                <Text size={16}>Specify database resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">Specify your database CPU and RAM.</Text>
+                <Text color="helper">Specify your datastore CPU and RAM.</Text>
                 {errors.config?.instanceClass?.message && (
                   <AppearingErrorContainer>
                     <Spacer y={0.5} />
@@ -136,7 +123,7 @@ const DatabaseFormElasticacheRedis: React.FC<Props> = ({
                 />
               </>,
               <>
-                <Text size={16}>Database credentials</Text>
+                <Text size={16}>View credentials</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
                   These credentials never leave your own cloud environment. You

+ 8 - 21
dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx

@@ -8,15 +8,19 @@ import { v4 as uuidv4 } from "uuid";
 import Back from "components/porter/Back";
 import ClickToCopy from "components/porter/ClickToCopy";
 import Container from "components/porter/Container";
-import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
 import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import {
+  dbFormValidator,
+  type DatabaseTemplate,
+  type DbFormData,
+  type ResourceOption,
+} from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
 import Resources from "../tabs/Resources";
-import { type DatabaseTemplate } from "../types";
 import DatabaseForm, {
   AppearingErrorContainer,
   Blur,
@@ -27,7 +31,6 @@ import DatabaseForm, {
   RevealButton,
   StyledConfigureTemplate,
 } from "./DatabaseForm";
-import { dbFormValidator, type DbFormData, type ResourceOption } from "./types";
 
 type Props = RouteComponentProps & {
   template: DatabaseTemplate;
@@ -53,7 +56,6 @@ const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
   const {
     setValue,
     formState: { errors },
-    register,
     watch,
   } = dbForm;
 
@@ -94,22 +96,7 @@ const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
           <DatabaseForm
             steps={[
               <>
-                <Text size={16}>Specify database name</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Lowercase letters, numbers, and &quot;-&quot; only.
-                </Text>
-                <Spacer height="20px" />
-                <ControlledInput
-                  placeholder="ex: academic-sophon-db"
-                  type="text"
-                  width="300px"
-                  error={errors.name?.message}
-                  {...register("name")}
-                />
-              </>,
-              <>
-                <Text size={16}>Specify database resources</Text>
+                <Text size={16}>Specify resources</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
                   Specify your database CPU, RAM, and storage.
@@ -137,7 +124,7 @@ const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
                 />
               </>,
               <>
-                <Text size={16}>View database credentials</Text>
+                <Text size={16}>View credentials</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
                   These credentials never leave your own cloud environment. You

+ 7 - 5
dashboard/src/main/home/database-dashboard/icons.tsx

@@ -1,12 +1,14 @@
+import {
+  DATABASE_TYPE_ELASTICACHE,
+  DATABASE_TYPE_RDS,
+} from "lib/databases/types";
+
 import awsRDS from "assets/amazon-rds.png";
 import awsElasticache from "assets/aws-elasticache.png";
 
 export const datastoreIcons: Record<string, string> = {
-  ENUM_DATASTORE_ELASTICACHE_REDIS: awsElasticache,
-  ENUM_DATASTORE_ELASTICACHE_MEMCACHED: awsElasticache,
-  ENUM_DATASTORE_RDS_POSTGRESQL: awsRDS,
-  ENUM_DATASTORE_RDS_MYSQL: awsRDS,
-  ENUM_DATASTORE_RDS_AURORA_POSTGRESQL: awsRDS,
+  [DATABASE_TYPE_ELASTICACHE]: awsElasticache,
+  [DATABASE_TYPE_RDS]: awsRDS,
 };
 
 export const getDatastoreIcon = (datastoreType: string): string => {

+ 70 - 65
dashboard/src/main/home/database-dashboard/tabs/DatabaseEnvTab.tsx

@@ -5,13 +5,13 @@ import CopyToClipboard from "components/CopyToClipboard";
 import Helper from "components/form-components/Helper";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-
 import EnvGroupArray from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
-import { DatastoreEnvWithSource } from "../types";
-import DatabaseLinkedApp from "./DatabaseLinkedApp";
+import { type DatastoreEnvWithSource } from "lib/databases/types";
 
 import copy from "assets/copy-left.svg";
 
+import DatabaseLinkedApp from "./DatabaseLinkedApp";
+
 type Props = {
   envData: DatastoreEnvWithSource;
   connectionString?: string;
@@ -25,9 +25,7 @@ export type KeyValueType = {
   deleted: boolean;
 };
 
-const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString
-}) => {
-
+const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString }) => {
   const setKeys = (): KeyValueType[] => {
     const keys: KeyValueType[] = [];
     if (envData != null) {
@@ -41,61 +39,68 @@ const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString
       });
     }
 
-    return (keys)
-  }
+    return keys;
+  };
 
   const renderLinkedApplications = (): JSX.Element => {
     if (envData.linked_applications.length === 0) {
-      return <InnerWrapper>
-        <Text size={16}> Linked Applications</Text><Spacer y={.5} />
-        <Helper>
-          No applications are linked to the &quot;{envData.name}&quot; env group.
-        </Helper>
-      </InnerWrapper>;
+      return (
+        <InnerWrapper>
+          <Text size={16}> Linked Applications</Text>
+          <Spacer y={0.5} />
+          <Helper>
+            No applications are linked to the &quot;{envData.name}&quot; env
+            group.
+          </Helper>
+        </InnerWrapper>
+      );
     }
 
-    return <InnerWrapper>
-      <Text size={16}> Linked Applications</Text><Spacer y={.5} />
-      {envData.linked_applications.map((appName, index) => <DatabaseLinkedApp appName={appName} key={index}></DatabaseLinkedApp>)}
-    </InnerWrapper>;
-  }
+    return (
+      <InnerWrapper>
+        <Text size={16}> Linked Applications</Text>
+        <Spacer y={0.5} />
+        {envData.linked_applications.map((appName, index) => (
+          <DatabaseLinkedApp appName={appName} key={index}></DatabaseLinkedApp>
+        ))}
+      </InnerWrapper>
+    );
+  };
 
   return (
     <StyledTemplateComponent>
       <InnerWrapper>
         <Text size={16}>Environment Variables</Text>
         <Helper>
-          These environment variables are available to your applications once the &quot;{envData.name}&quot; env group is linked to your app.
+          These environment variables are available to your applications once
+          the &quot;{envData.name}&quot; env group is linked to your app.
         </Helper>
         <EnvGroupArray
-            values={setKeys()}
-            setValues={(_: any) => {}}
-            fileUpload={true}
-            secretOption={true}
-            disabled={
-                true
-            }
+          values={setKeys()}
+          setValues={(_) => {}}
+          fileUpload={true}
+          secretOption={true}
+          disabled={true}
         />
       </InnerWrapper>
-      {
-        connectionString &&
-          <InnerWrapper>
-            <Text size={16}>Connection String</Text>
-            <Spacer y={.5} />
-            <IdContainer>
-              <ConnectionContainer>
-                <IconWithName>Connection String: </IconWithName>
-                <CopyContainer>
-                  <IdText> {connectionString}</IdText>
-                  <CopyToClipboard text={connectionString.toString()}>
-                    <CopyIcon src={copy} alt="copy" />
-                  </CopyToClipboard>
-                </CopyContainer>
-              </ConnectionContainer>
-            </IdContainer>
-            <Spacer y={1} />
-          </InnerWrapper>
-      }
+      {connectionString && (
+        <InnerWrapper>
+          <Text size={16}>Connection String</Text>
+          <Spacer y={0.5} />
+          <IdContainer>
+            <ConnectionContainer>
+              <IconWithName>Connection String: </IconWithName>
+              <CopyContainer>
+                <IdText> {connectionString}</IdText>
+                <CopyToClipboard text={connectionString.toString()}>
+                  <CopyIcon src={copy} alt="copy" />
+                </CopyToClipboard>
+              </CopyContainer>
+            </ConnectionContainer>
+          </IdContainer>
+          <Spacer y={1} />
+        </InnerWrapper>
+      )}
 
       {renderLinkedApplications()}
     </StyledTemplateComponent>
@@ -105,29 +110,29 @@ const DatabaseEnvTab: React.FC<Props> = ({ envData, connectionString
 export default DatabaseEnvTab;
 
 const StyledTemplateComponent = styled.div`
-width: 100%;
-animation: fadeIn 0.3s 0s;
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
   }
-}
 `;
 
 const IdContainer = styled.div`
-    color: #aaaabb;
-    border-radius: 5px;
-    padding: 5px;
-    padding-left: 10px;
-    display: block;
-    width: 100%;
-    border-radius: 5px;
-    background: ${(props) => props.theme.fg};
-    border: 1px solid ${({ theme }) => theme.border};
-    margin-bottom: 10px;
+  color: #aaaabb;
+  border-radius: 5px;
+  padding: 5px;
+  padding-left: 10px;
+  display: block;
+  width: 100%;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid ${({ theme }) => theme.border};
+  margin-bottom: 10px;
 `;
 
 const ConnectionContainer = styled.div`

+ 28 - 51
dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx

@@ -1,58 +1,33 @@
 import React, { useContext } from "react";
-import { useHistory, useLocation } from "react-router";
+import { useHistory } from "react-router";
 import styled from "styled-components";
 
 import Button from "components/porter/Button";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import { useDatabaseMethods } from "lib/hooks/useDatabaseMethods";
 
-import api from "shared/api";
 import { Context } from "shared/Context";
-import { pushFiltered } from "shared/routing";
 
-import { CloudProviderDatastore } from "../types";
+import { useDatabaseContext } from "../DatabaseContextProvider";
 
-type Props = {
-  item: CloudProviderDatastore
-};
-
-const SettingsTab: React.FC<Props> = ({ item }) => {
-  const {
-    setCurrentError,
-    setCurrentOverlay,
-  } = useContext(Context);
+const SettingsTab: React.FC = () => {
+  const { setCurrentOverlay } = useContext(Context);
   const history = useHistory();
-  const location = useLocation();
+  const { datastore } = useDatabaseContext();
+  const { deleteDatastore } = useDatabaseMethods();
   const handleDeletionSubmit = async (): Promise<void> => {
-    if (setCurrentOverlay === undefined || setCurrentError === undefined) {
+    if (setCurrentOverlay == null) {
       return;
     }
 
-    setCurrentOverlay(null);
     try {
-      await api.deleteDatastore(
-        "<token>",
-        {
-          name: item.datastore.name,
-          type: item.datastore.type,
-        },
-        {
-          project_id: item.project_id,
-          cloud_provider_name: item.cloud_provider_name,
-          cloud_provider_id: item.cloud_provider_id,
-        }
-      );
+      await deleteDatastore(datastore.name);
+      setCurrentOverlay(null);
+      history.push("/databases");
     } catch (error) {
-      setCurrentError("Couldn't uninstall database, please try again");
+      // todo: handle error
     }
-    pushFiltered(
-      {
-        history,
-        location,
-      },
-      `/databases`,
-      []
-    );
   };
 
   const handleDeletionClick = async (): Promise<void> => {
@@ -61,23 +36,25 @@ const SettingsTab: React.FC<Props> = ({ item }) => {
     }
 
     setCurrentOverlay({
-      message: `Are you sure you want to delete ${item.datastore.name}?`,
+      message: `Are you sure you want to delete ${datastore.name}?`,
       onYes: handleDeletionSubmit,
-      onNo: () => setCurrentOverlay(null),
+      onNo: () => {
+        setCurrentOverlay(null);
+      },
     });
-  }
+  };
 
   return (
     <StyledTemplateComponent>
       <InnerWrapper>
-        <Text size={16}>Delete &quot;{item.datastore.name}&quot;</Text>
+        <Text size={16}>Delete &quot;{datastore.name}&quot;</Text>
         <Spacer y={0.5} />
         <Text color="helper">
           Delete this database and all of its resources.
         </Text>
         <Spacer y={0.5} />
         <Button color="#b91133" onClick={handleDeletionClick}>
-          Delete {item.datastore.name}
+          Delete {datastore.name}
         </Button>
       </InnerWrapper>
     </StyledTemplateComponent>
@@ -87,16 +64,16 @@ const SettingsTab: React.FC<Props> = ({ item }) => {
 export default SettingsTab;
 
 const StyledTemplateComponent = styled.div`
-width: 100%;
-animation: fadeIn 0.3s 0s;
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
+  width: 100%;
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
   }
-}
 `;
 
 const InnerWrapper = styled.div<{ full?: boolean }>`

+ 0 - 95
dashboard/src/main/home/database-dashboard/types.ts

@@ -1,95 +0,0 @@
-import { z } from "zod";
-
-import { type ResourceOption } from "./forms/types";
-
-export const datastoreEnvValidator = z.object({
-  name: z.string(),
-  linked_applications: z.string().array().default([]),
-  secret_variables: z.record(z.string()).default({}),
-  variables: z.record(z.string()).default({}),
-  version: z.number(),
-});
-
-export type DatastoreEnvWithSource = z.infer<typeof datastoreEnvValidator>;
-
-export const datastoreMetadataValidator = z.object({
-  name: z.string(),
-  value: z.string().default(""),
-});
-
-export type DatastoreMetadataWithSource = z.infer<
-  typeof datastoreMetadataValidator
->;
-
-export const datastoreValidator = z.object({
-  name: z.string(),
-  type: z.string(),
-  status: z.string().default(""),
-  metadata: datastoreMetadataValidator.array().default([]),
-  env: datastoreEnvValidator.optional(),
-  connection_string: z.string().default(""),
-});
-
-export type DatastoreWithSource = z.infer<typeof datastoreValidator>;
-
-export const datastoreListResponseValidator = z.object({
-  datastores: datastoreValidator.array(),
-});
-
-export const cloudProviderValidator = z.object({
-  cloud_provider_id: z.string(),
-  project_id: z.number(),
-});
-
-export type CloudProviderWithSource = z.infer<typeof cloudProviderValidator>;
-
-export const cloudProviderListResponseValidator = z.object({
-  accounts: cloudProviderValidator.array(),
-});
-
-export const cloudProviderDatastoreSchema = z.object({
-  project_id: z.number(),
-  cloud_provider_name: z.string(),
-  cloud_provider_id: z.string(),
-  datastore: datastoreValidator,
-});
-
-export type CloudProviderDatastore = z.infer<
-  typeof cloudProviderDatastoreSchema
->;
-
-export type DatabaseEngine =
-  | typeof DATABASE_ENGINE_POSTGRES
-  | typeof DATABASE_ENGINE_REDIS
-  | typeof DATABASE_ENGINE_MEMCACHED;
-export const DATABASE_ENGINE_POSTGRES = {
-  name: "postgres" as const,
-  displayName: "PostgreSQL",
-};
-export const DATABASE_ENGINE_REDIS = {
-  name: "redis" as const,
-  displayName: "Redis",
-};
-export const DATABASE_ENGINE_MEMCACHED = {
-  name: "memcached" as const,
-  displayName: "Memcached",
-};
-
-export type DatabaseType =
-  | typeof DATABASE_TYPE_RDS
-  | typeof DATABASE_TYPE_AURORA
-  | typeof DATABASE_TYPE_ELASTICACHE;
-export const DATABASE_TYPE_RDS = "rds" as const;
-export const DATABASE_TYPE_AURORA = "aurora" as const;
-export const DATABASE_TYPE_ELASTICACHE = "elasticache" as const;
-
-export type DatabaseTemplate = {
-  type: DatabaseType;
-  engine: DatabaseEngine;
-  icon: string;
-  name: string;
-  description: string;
-  disabled: boolean;
-  instanceTiers: ResourceOption[];
-  formTitle: string;
-};

+ 2 - 2
dashboard/src/main/home/database-dashboard/utils.tsx

@@ -1,7 +1,7 @@
-import { type DatastoreWithSource } from "./types";
+import { type ClientDatastore } from "lib/databases/types";
 
 export const datastoreField = (
-  datastore: DatastoreWithSource,
+  datastore: ClientDatastore,
   field: string
 ): string => {
   if (datastore.metadata?.length === 0) {

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

@@ -20,9 +20,9 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Clusters from "./Clusters";
 import ProjectSectionContainer from "./ProjectSectionContainer";
-import { RouteComponentProps, withRouter } from "react-router";
+import { type RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, pushFiltered } from "shared/routing";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
 import SidebarLink from "./SidebarLink";
 import { overrideInfraTabEnabled } from "utils/infrastructure";
 import ClusterListContainer from "./ClusterListContainer";
@@ -42,7 +42,7 @@ type StateType = {
   pressingCtrl: boolean;
   showTooltip: boolean;
   forceCloseDrawer: boolean;
-  showLinkTooltip: { [linkKey: string]: boolean };
+  showLinkTooltip: Record<string, boolean>;
 };
 
 class Sidebar extends Component<PropsType, StateType> {
@@ -113,8 +113,8 @@ class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderProjectContents = () => {
-    let { currentView } = this.props;
-    let {
+    const { currentView } = this.props;
+    const {
       currentProject,
       user,
       currentCluster,
@@ -133,8 +133,7 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
-          {currentProject &&
-            currentProject.managed_infra_enabled &&
+          {currentProject?.managed_infra_enabled &&
             (user?.isPorterUser ||
               overrideInfraTabEnabled({ projectID: currentProject.id })) && (
               <NavButton path={"/infrastructure"}>
@@ -205,6 +204,15 @@ class Sidebar extends Component<PropsType, StateType> {
                   Integrations
                 </NavButton>
               )}
+            {currentProject.db_enabled && (
+              <NavButton
+                path="/databases"
+                active={window.location.pathname.startsWith("/apps")}
+              >
+                <Img src={database} />
+                Databases
+              </NavButton>
+            )}
             {currentCluster && (
               <>
                 <Spacer y={0.5} />
@@ -218,15 +226,6 @@ class Sidebar extends Component<PropsType, StateType> {
               <Img src={applications} />
               Applications
             </NavButton>
-            {currentProject.db_enabled && (
-              <NavButton
-                path="/databases"
-                active={window.location.pathname.startsWith("/apps")}
-              >
-                <Img src={database} />
-                Databases
-              </NavButton>
-            )}
             <NavButton
               path="/addons"
               active={window.location.pathname.startsWith("/addons")}

+ 59 - 21
dashboard/src/shared/api.tsx

@@ -1246,15 +1246,16 @@ const getAppTemplate = baseApi<
 });
 
 const listLatestAddons = baseApi<
-{
-  deployment_target_id?: string;
-},
-{
-  projectId: number;
-  clusterId: number;
-}>("GET", ({ projectId, clusterId }) => {
+  {
+    deployment_target_id?: string;
+  },
+  {
+    projectId: number;
+    clusterId: number;
+  }
+>("GET", ({ projectId, clusterId }) => {
   return `/api/projects/${projectId}/clusters/${clusterId}/addons/latest`;
-})
+});
 
 const getGitlabProcfileContents = baseApi<
   {
@@ -2679,12 +2680,9 @@ const getAwsCloudProviders = baseApi<
   {
     project_id: number;
   }
->(
-  "GET",
-  ({ project_id }) => {
-    return `/api/projects/${project_id}/cloud-providers/aws`;
-  }
-);
+>("GET", ({ project_id }) => {
+  return `/api/projects/${project_id}/cloud-providers/aws`;
+});
 
 const getDatabases = baseApi<
   {},
@@ -2711,7 +2709,15 @@ const getDatastores = baseApi<
   }
 >(
   "GET",
-  ({ project_id, cloud_provider_name, cloud_provider_id, datastore_name, datastore_type, include_env_group, include_metadata }) => {
+  ({
+    project_id,
+    cloud_provider_name,
+    cloud_provider_id,
+    datastore_name,
+    datastore_type,
+    include_env_group,
+    include_metadata,
+  }) => {
     const queryParams = new URLSearchParams();
 
     if (datastore_name) {
@@ -2734,20 +2740,49 @@ const getDatastores = baseApi<
   }
 );
 
-const deleteDatastore = baseApi<
+const listDatastores = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>("GET", ({ project_id }) => {
+  return `/api/projects/${project_id}/datastores`;
+});
+
+const getDatastore = baseApi<
+  {},
+  {
+    project_id: number;
+    datastore_name: string;
+  }
+>("GET", ({ project_id, datastore_name }) => {
+  return `/api/projects/${project_id}/datastores/${datastore_name}`;
+});
+
+const updateDatastore = baseApi<
   {
     name: string;
-    type: string;
+    type: "RDS" | "ELASTICACHE";
+    engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
+    values: any;
   },
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/datastores`
+);
+
+const deleteDatastore = baseApi<
+  {},
   {
     project_id: number;
-    cloud_provider_name: string;
-    cloud_provider_id: string;
+    datastore_name: string;
   }
 >(
   "DELETE",
-  ({ project_id, cloud_provider_name, cloud_provider_id }) =>
-    `/api/projects/${project_id}/cloud-providers/${cloud_provider_name}/${cloud_provider_id}/datastores`
+  ({ project_id, datastore_name }) =>
+    `/api/projects/${project_id}/datastores/${datastore_name}`
 );
 
 const getPreviousLogsForContainer = baseApi<
@@ -3605,6 +3640,9 @@ export default {
   getAwsCloudProviders,
   getDatabases,
   getDatastores,
+  listDatastores,
+  getDatastore,
+  updateDatastore,
   deleteDatastore,
   getPreviousLogsForContainer,
   upgradePorterAgent,

+ 4 - 4
internal/datastore/create.go

@@ -9,8 +9,8 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// CreateOrGetDatastoreRecordInput is the input type for CreateOrGetDatastoreRecord
-type CreateOrGetDatastoreRecordInput struct {
+// CreateOrGetRecordInput is the input type for CreateOrGetDatastoreRecord
+type CreateOrGetRecordInput struct {
 	ProjectID uint
 	ClusterID uint
 	Name      string
@@ -21,8 +21,8 @@ type CreateOrGetDatastoreRecordInput struct {
 	ClusterRepository   repository.ClusterRepository
 }
 
-// CreateOrGetDatastoreRecord creates a datastore record if it does not exist, or returns the existing one if it does
-func CreateOrGetDatastoreRecord(ctx context.Context, inp CreateOrGetDatastoreRecordInput) (*models.Datastore, error) {
+// CreateOrGetRecord creates a datastore record if it does not exist, or returns the existing one if it does
+func CreateOrGetRecord(ctx context.Context, inp CreateOrGetRecordInput) (*models.Datastore, error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-or-get-datastore-record")
 	defer span.End()
 

+ 45 - 0
internal/datastore/delete.go

@@ -0,0 +1,45 @@
+package datastore
+
+import (
+	"context"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeleteRecordInput is the input type for DeleteDatastoreRecord
+type DeleteRecordInput struct {
+	ProjectID uint
+	Name      string
+
+	DatastoreRepository repository.DatastoreRepository
+}
+
+// DeleteRecord deletes a datastore record by name
+func DeleteRecord(ctx context.Context, inp DeleteRecordInput) (*models.Datastore, error) {
+	ctx, span := telemetry.NewSpan(ctx, "delete-datastore-record")
+	defer span.End()
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: inp.ProjectID},
+		telemetry.AttributeKV{Key: "name", Value: inp.Name},
+	)
+
+	datastore, err := inp.DatastoreRepository.GetByProjectIDAndName(ctx, inp.ProjectID, inp.Name)
+	if err != nil {
+		return datastore, telemetry.Error(ctx, span, err, "error reading datastore by project id and name")
+	}
+
+	if datastore == nil || datastore.ID == uuid.Nil {
+		return datastore, telemetry.Error(ctx, span, nil, "datastore not found in table")
+	}
+
+	datastore, err = inp.DatastoreRepository.Delete(ctx, datastore)
+	if err != nil {
+		return datastore, telemetry.Error(ctx, span, err, "error deleting datastore")
+	}
+
+	return datastore, nil
+}

+ 4 - 0
internal/repository/datastore.go

@@ -12,4 +12,8 @@ type DatastoreRepository interface {
 	GetByProjectIDAndName(ctx context.Context, projectID uint, name string) (*models.Datastore, error)
 	// Insert inserts a datastore into the database
 	Insert(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error)
+	// ListByProjectID retrieves a list of datastores by project id
+	ListByProjectID(ctx context.Context, projectID uint) ([]*models.Datastore, error)
+	// Delete deletes a datastore by id
+	Delete(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error)
 }

+ 37 - 0
internal/repository/gorm/datastore.go

@@ -89,3 +89,40 @@ func (repo *DatastoreRepository) GetByProjectIDAndName(ctx context.Context, proj
 
 	return datastore, nil
 }
+
+// ListByProjectID returns a list of datastores by project ID
+func (repo *DatastoreRepository) ListByProjectID(ctx context.Context, projectId uint) ([]*models.Datastore, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-list-datastores")
+	defer span.End()
+
+	if projectId == 0 {
+		return nil, telemetry.Error(ctx, span, nil, "project id is 0")
+	}
+
+	datastores := []*models.Datastore{}
+	if err := repo.db.Where("project_id = ?", projectId).Find(&datastores).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error finding datastores")
+	}
+
+	return datastores, nil
+}
+
+// Delete deletes a datastore by id
+func (repo *DatastoreRepository) Delete(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-delete-datastore")
+	defer span.End()
+
+	if datastore == nil {
+		return nil, telemetry.Error(ctx, span, nil, "datastore is nil")
+	}
+
+	if datastore.ID == uuid.Nil {
+		return nil, telemetry.Error(ctx, span, nil, "datastore id is nil")
+	}
+
+	if err := repo.db.Delete(&datastore).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error deleting datastore")
+	}
+
+	return datastore, nil
+}

+ 10 - 0
internal/repository/test/datastore.go

@@ -27,3 +27,13 @@ func (repo *DatastoreRepository) GetByProjectIDAndName(ctx context.Context, proj
 func (repo *DatastoreRepository) Insert(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error) {
 	return nil, errors.New("cannot write database")
 }
+
+// ListByProjectID retrieves a list of datastores by project id
+func (repo *DatastoreRepository) ListByProjectID(ctx context.Context, projectID uint) ([]*models.Datastore, error) {
+	return nil, errors.New("cannot read database")
+}
+
+// Delete deletes a datastore by id
+func (repo *DatastoreRepository) Delete(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error) {
+	return nil, errors.New("cannot write database")
+}