Jelajahi Sumber

Display datastore connection information for mgmt cluster datastores (#4417)

Feroze Mohideen 2 tahun lalu
induk
melakukan
302e394760

+ 3 - 2
api/server/handlers/datastore/create_proxy.go

@@ -13,6 +13,7 @@ import (
 	"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"
 )
@@ -22,7 +23,7 @@ type CreateDatastoreProxyResponse struct {
 	// PodName is the name of the pod that was created
 	PodName string `json:"pod_name"`
 	// Credential is the credential used to connect to the datastore
-	Credential Credential `json:"credential"`
+	Credential datastore.Credential `json:"credential"`
 	// ClusterID is the ID of the cluster that the pod was created in
 	ClusterID uint `json:"cluster_id"`
 	// Namespace is the namespace that the pod was created in
@@ -104,7 +105,7 @@ func (c *CreateDatastoreProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 
 	resp = CreateDatastoreProxyResponse{
 		PodName: ccpResp.Msg.PodName,
-		Credential: Credential{
+		Credential: datastore.Credential{
 			Host:         ccpResp.Msg.Credential.Host,
 			Port:         int(ccpResp.Msg.Credential.Port),
 			Username:     ccpResp.Msg.Credential.Username,

+ 19 - 27
api/server/handlers/datastore/credential.go

@@ -13,46 +13,38 @@ import (
 	"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"
 )
 
-// Credential has all information about connecting to a datastore
-type Credential struct {
-	Host         string `json:"host"`
-	Port         int    `json:"port"`
-	Username     string `json:"username"`
-	Password     string `json:"password"`
-	DatabaseName string `json:"database_name"`
-}
-
-// GetDatastoreCredentialsResponse describes the datastore credentials response body
-type GetDatastoreCredentialsResponse struct {
+// GetDatastoreCredentialResponse describes the datastore credential response body
+type GetDatastoreCredentialResponse struct {
 	// Credential is the credential that has been retrieved for this datastore
-	Credential Credential `json:"credential"`
+	Credential datastore.Credential `json:"credential"`
 }
 
-// GetDatastoreCredentialsHandler is a struct for retrieving credentials for datastore
-type GetDatastoreCredentialsHandler struct {
+// GetDatastoreCredentialHandler is a struct for retrieving credentials for datastore
+type GetDatastoreCredentialHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
 }
 
-// NewGetDatastoreCredentialsHandler returns a DatastoreCredentialsHandler
-func NewGetDatastoreCredentialsHandler(
+// NewGetDatastoreCredentialHandler returns a GetDatastoreCredentialHandler
+func NewGetDatastoreCredentialHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *GetDatastoreCredentialsHandler {
-	return &GetDatastoreCredentialsHandler{
+) *GetDatastoreCredentialHandler {
+	return &GetDatastoreCredentialHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
 // ServeHTTP retrieves the credentials for a datastore
-func (c *GetDatastoreCredentialsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-datastore-credentials")
+func (c *GetDatastoreCredentialHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-datastore-credential")
 	defer span.End()
 
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
@@ -63,7 +55,7 @@ func (c *GetDatastoreCredentialsHandler) ServeHTTP(w http.ResponseWriter, r *htt
 	}
 	projectId := int64(project.ID)
 
-	var resp GetDatastoreCredentialsResponse
+	var resp GetDatastoreCredentialResponse
 
 	datastoreName, reqErr := requestutils.GetURLParamString(r, types.URLParamDatastoreName)
 	if reqErr != nil {
@@ -86,25 +78,25 @@ func (c *GetDatastoreCredentialsHandler) ServeHTTP(w http.ResponseWriter, r *htt
 		return
 	}
 
-	message := porterv1.CreateDatastoreProxyRequest{
+	message := porterv1.DatastoreCredentialRequest{
 		ProjectId:   projectId,
 		DatastoreId: datastoreRecord.ID.String(),
 	}
 	req := connect.NewRequest(&message)
-	ccpResp, err := c.Config().ClusterControlPlaneClient.CreateDatastoreProxy(ctx, req)
+	ccpResp, err := c.Config().ClusterControlPlaneClient.DatastoreCredential(ctx, req)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error creating datastore proxy")
+		err = telemetry.Error(ctx, span, err, "error getting datastore credential")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 	if ccpResp == nil || ccpResp.Msg == nil {
-		err = telemetry.Error(ctx, span, nil, "error creating datastore proxy")
+		err = telemetry.Error(ctx, span, nil, "datastore credential not found")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	resp = GetDatastoreCredentialsResponse{
-		Credential: Credential{
+	resp = GetDatastoreCredentialResponse{
+		Credential: datastore.Credential{
 			Host:         ccpResp.Msg.Credential.Host,
 			Port:         int(ccpResp.Msg.Credential.Port),
 			Username:     ccpResp.Msg.Credential.Username,

+ 46 - 9
api/server/handlers/datastore/get.go

@@ -90,7 +90,7 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			return
 		}
 
-		datastore, err := c.LEGACY_handleGetDatastore(ctx, project.ID, awsArn.AccountID, datastoreName)
+		datastore, err := c.LEGACY_handleGetDatastore(ctx, project.ID, awsArn.AccountID, datastoreName, datastoreRecord.ID)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error retrieving datastore")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
@@ -151,7 +151,7 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	b64 := base64.StdEncoding.EncodeToString(encoded)
 
-	datastore := datastore.Datastore{
+	ds := datastore.Datastore{
 		Name:                              datastoreRecord.Name,
 		Type:                              datastoreRecord.Type,
 		Engine:                            datastoreRecord.Engine,
@@ -162,16 +162,34 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		B64Proto:                          b64,
 	}
 
-	resp.Datastore = datastore
+	message := porterv1.DatastoreCredentialRequest{
+		ProjectId:   int64(project.ID),
+		DatastoreId: datastoreRecord.ID.String(),
+	}
+	credentialReq := connect.NewRequest(&message)
+	credentialCcpResp, err := c.Config().ClusterControlPlaneClient.DatastoreCredential(ctx, credentialReq)
+	if err == nil && credentialCcpResp != nil && credentialCcpResp.Msg != nil {
+		// the credential may not exist because the datastore is not yet ready
+		ds.Credential = datastore.Credential{
+			Host:         credentialCcpResp.Msg.Credential.Host,
+			Port:         int(credentialCcpResp.Msg.Credential.Port),
+			Username:     credentialCcpResp.Msg.Credential.Username,
+			Password:     credentialCcpResp.Msg.Credential.Password,
+			DatabaseName: credentialCcpResp.Msg.Credential.DatabaseName,
+		}
+	}
+
+	resp.Datastore = ds
+
 	c.WriteResult(w, r, resp)
 }
 
 // LEGACY_handleGetDatastore retrieves the datastore in the given project for datastores that are on the customer clusters rather than the management cluster
-func (c *GetDatastoreHandler) LEGACY_handleGetDatastore(ctx context.Context, projectId uint, accountId string, datastoreName string) (datastore.Datastore, error) {
+func (c *GetDatastoreHandler) LEGACY_handleGetDatastore(ctx context.Context, projectId uint, accountId string, datastoreName string, datastoreId uuid.UUID) (datastore.Datastore, error) {
 	ctx, span := telemetry.NewSpan(ctx, "legacy-handle-get-datastore")
 	defer span.End()
 
-	var datastore datastore.Datastore
+	var ds datastore.Datastore
 
 	datastores, err := Datastores(ctx, DatastoresInput{
 		ProjectID: projectId,
@@ -186,16 +204,35 @@ func (c *GetDatastoreHandler) LEGACY_handleGetDatastore(ctx context.Context, pro
 		DatastoreRepository: c.Repo().Datastore(),
 	})
 	if err != nil {
-		return datastore, err
+		return ds, err
 	}
 
 	if len(datastores) != 1 {
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-count", Value: len(datastores)})
 		if len(datastores) == 0 {
-			return datastore, telemetry.Error(ctx, span, nil, "datastore not found")
+			return ds, telemetry.Error(ctx, span, nil, "datastore not found")
+		}
+		return ds, telemetry.Error(ctx, span, nil, "unexpected number of datastores found matching filters")
+	}
+
+	ds = datastores[0]
+
+	message := porterv1.DatastoreCredentialRequest{
+		ProjectId:   int64(projectId),
+		DatastoreId: datastoreId.String(),
+	}
+	req := connect.NewRequest(&message)
+	ccpResp, err := c.Config().ClusterControlPlaneClient.DatastoreCredential(ctx, req)
+	// the credential may not exist because the datastore is not yet ready
+	if err == nil && ccpResp != nil && ccpResp.Msg != nil {
+		ds.Credential = datastore.Credential{
+			Host:         ccpResp.Msg.Credential.Host,
+			Port:         int(ccpResp.Msg.Credential.Port),
+			Username:     ccpResp.Msg.Credential.Username,
+			Password:     ccpResp.Msg.Credential.Password,
+			DatabaseName: ccpResp.Msg.Credential.DatabaseName,
 		}
-		return datastore, telemetry.Error(ctx, span, nil, "unexpected number of datastores found matching filters")
 	}
 
-	return datastores[0], nil
+	return ds, nil
 }

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

@@ -527,6 +527,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/datastores -> datastore.NewGetDatastoreCredentialHandler
+	getDatastoreCredentialEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/datastores/{%s}/credential", relPath, types.URLParamDatastoreName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getDatastoreCredentialHandler := datastore.NewGetDatastoreCredentialHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getDatastoreCredentialEndpoint,
+		Handler:  getDatastoreCredentialHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/datastores/{datastore_name}/create-proxy -> cluster.NewCreateDatastoreProxyHandler
 	createDatastoreProxyEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 14 - 3
dashboard/src/lib/databases/types.ts

@@ -19,6 +19,16 @@ export const datastoreMetadataValidator = z.object({
 export type DatastoreMetadataWithSource = z.infer<
   typeof datastoreMetadataValidator
 >;
+export const datastoreCredentialValidator = z.object({
+  host: z.string().optional().default(""),
+  port: z.number().optional().default(0),
+  username: z.string().optional().default(""),
+  database_name: z.string().optional().default(""),
+  password: z.string().optional().default(""),
+});
+export type DatastoreConnectionInfo = z.infer<
+  typeof datastoreCredentialValidator
+>;
 
 export const datastoreValidator = z.object({
   name: z.string(),
@@ -44,6 +54,7 @@ export const datastoreValidator = z.object({
   ]),
   cloud_provider: z.string().pipe(cloudProviderValidator.catch("UNKNOWN")),
   cloud_provider_credential_identifier: z.string(),
+  credential: datastoreCredentialValidator,
 });
 
 export type SerializedDatastore = z.infer<typeof datastoreValidator>;
@@ -177,7 +188,7 @@ const rdsPostgresConfigValidator = z.object({
   instanceClass: instanceTierValidator
     .default("unspecified")
     .refine((val) => val !== "unspecified", {
-      message: "Instance class is required",
+      message: "Instance tier is required",
     }),
   allocatedStorageGigabytes: z
     .number()
@@ -205,7 +216,7 @@ const auroraPostgresConfigValidator = z.object({
   instanceClass: instanceTierValidator
     .default("unspecified")
     .refine((val) => val !== "unspecified", {
-      message: "Instance class is required",
+      message: "Instance tier is required",
     }),
   allocatedStorageGigabytes: z
     .number()
@@ -229,7 +240,7 @@ const elasticacheRedisConfigValidator = z.object({
   instanceClass: instanceTierValidator
     .default("unspecified")
     .refine((val) => val !== "unspecified", {
-      message: "Instance class is required",
+      message: "Instance tier is required",
     }),
   // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
   databaseName: z.string().nonempty("Database name is required").default(""),

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

@@ -84,6 +84,7 @@ export const DatastoreContextProvider: React.FC<
       refetchOnWindowFocus: true,
     }
   );
+
   if (status === "loading" || !paramsExist) {
     return <Loading />;
   }

+ 16 - 77
dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import _ from "lodash";
 import { useForm } from "react-hook-form";
@@ -6,10 +6,7 @@ import { withRouter, type RouteComponentProps } from "react-router";
 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 Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
@@ -20,15 +17,14 @@ import {
 } from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import ConnectionInfo from "../shared/ConnectionInfo";
 import Resources from "../shared/Resources";
 import DatabaseForm, {
   AppearingErrorContainer,
-  Blur,
   CenterWrapper,
   DarkMatter,
   Div,
   Icon,
-  RevealButton,
   StyledConfigureTemplate,
 } from "./DatabaseForm";
 
@@ -37,9 +33,6 @@ type Props = RouteComponentProps & {
 };
 
 const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
-  const [currentStep, setCurrentStep] = useState<number>(0);
-  const [isPasswordHidden, setIsPasswordHidden] = useState<boolean>(true);
-
   const dbForm = useForm<DbFormData>({
     resolver: zodResolver(dbFormValidator),
     reValidateMode: "onSubmit",
@@ -59,24 +52,12 @@ const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
     watch,
   } = dbForm;
 
-  const watchName = watch("name", "");
   const watchTier = watch("config.instanceClass", "unspecified");
 
   const watchDbName = watch("config.databaseName");
   const watchDbUsername = watch("config.masterUsername");
   const watchDbPassword = watch("config.masterUserPassword");
 
-  useEffect(() => {
-    let newStep = 0;
-    if (watchName) {
-      newStep = 1;
-    }
-    if (watchTier !== "unspecified") {
-      newStep = 3;
-    }
-    setCurrentStep(Math.max(newStep, currentStep));
-  }, [watchName, watchTier]);
-
   return (
     <CenterWrapper>
       <Div>
@@ -124,68 +105,26 @@ const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
                 />
               </>,
               <>
-                <Text size={16}>View credentials</Text>
+                <Text size={16}>Credentials</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  These credentials never leave your own cloud environment. You
-                  will be able to automatically import these credentials from
-                  any app.
+                  These credentials never leave your own cloud environment. Your
+                  app will use them to connect to this datastore.
                 </Text>
                 <Spacer height="20px" />
-                <Fieldset>
-                  <Text>Postgres DB name</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {watchDbName}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres username</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {watchDbUsername}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres password</Text>
-                  <Spacer y={0.5} />
-                  <Container row>
-                    {isPasswordHidden ? (
-                      <>
-                        <Blur>{watchDbPassword}</Blur>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => {
-                            setIsPasswordHidden(false);
-                          }}
-                        >
-                          Reveal
-                        </RevealButton>
-                      </>
-                    ) : (
-                      <>
-                        <ClickToCopy color="helper">
-                          {watchDbPassword}
-                        </ClickToCopy>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => {
-                            setIsPasswordHidden(true);
-                          }}
-                        >
-                          Hide
-                        </RevealButton>
-                      </>
-                    )}
-                  </Container>
-                </Fieldset>
+                <ConnectionInfo
+                  connectionInfo={{
+                    host: "(determined after creation)",
+                    port: 5432,
+                    password: watchDbPassword,
+                    username: watchDbUsername,
+                    database_name: watchDbName,
+                  }}
+                  type={template.type}
+                />
               </>,
             ]}
-            currentStep={currentStep}
+            currentStep={100}
             form={dbForm}
           />
         </StyledConfigureTemplate>

+ 16 - 59
dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import _ from "lodash";
 import { useForm } from "react-hook-form";
@@ -6,10 +6,7 @@ import { withRouter, type RouteComponentProps } from "react-router";
 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 Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
@@ -20,15 +17,14 @@ import {
 } from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import ConnectionInfo from "../shared/ConnectionInfo";
 import Resources from "../shared/Resources";
 import DatabaseForm, {
   AppearingErrorContainer,
-  Blur,
   CenterWrapper,
   DarkMatter,
   Div,
   Icon,
-  RevealButton,
   StyledConfigureTemplate,
 } from "./DatabaseForm";
 
@@ -40,9 +36,6 @@ const DatabaseFormElasticacheRedis: React.FC<Props> = ({
   history,
   template,
 }) => {
-  const [currentStep, setCurrentStep] = useState<number>(0);
-  const [isPasswordHidden, setIsPasswordHidden] = useState<boolean>(true);
-
   const dbForm = useForm<DbFormData>({
     resolver: zodResolver(dbFormValidator),
     reValidateMode: "onSubmit",
@@ -62,22 +55,10 @@ const DatabaseFormElasticacheRedis: React.FC<Props> = ({
     watch,
   } = dbForm;
 
-  const watchName = watch("name", "");
   const watchTier = watch("config.instanceClass", "unspecified");
 
   const watchDbPassword = watch("config.masterUserPassword");
 
-  useEffect(() => {
-    let newStep = 0;
-    if (watchName) {
-      newStep = 1;
-    }
-    if (watchTier !== "unspecified") {
-      newStep = 3;
-    }
-    setCurrentStep(Math.max(newStep, currentStep));
-  }, [watchName, watchTier]);
-
   return (
     <CenterWrapper>
       <Div>
@@ -123,50 +104,26 @@ const DatabaseFormElasticacheRedis: React.FC<Props> = ({
                 />
               </>,
               <>
-                <Text size={16}>View credentials</Text>
+                <Text size={16}>Credentials</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  These credentials never leave your own cloud environment. You
-                  will be able to automatically import these credentials from
-                  any app.
+                  These credentials never leave your own cloud environment. Your
+                  app will use them to connect to this datastore.
                 </Text>
                 <Spacer height="20px" />
-                <Fieldset>
-                  <Text>Redis token</Text>
-                  <Spacer y={0.5} />
-                  <Container row>
-                    {isPasswordHidden ? (
-                      <>
-                        <Blur>{watchDbPassword}</Blur>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => {
-                            setIsPasswordHidden(false);
-                          }}
-                        >
-                          Reveal
-                        </RevealButton>
-                      </>
-                    ) : (
-                      <>
-                        <ClickToCopy color="helper">
-                          {watchDbPassword}
-                        </ClickToCopy>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => {
-                            setIsPasswordHidden(true);
-                          }}
-                        >
-                          Hide
-                        </RevealButton>
-                      </>
-                    )}
-                  </Container>
-                </Fieldset>
+                <ConnectionInfo
+                  connectionInfo={{
+                    host: "(determined after creation)",
+                    port: 6379,
+                    password: watchDbPassword,
+                    username: "",
+                    database_name: "",
+                  }}
+                  type={template.type}
+                />
               </>,
             ]}
-            currentStep={currentStep}
+            currentStep={100}
             form={dbForm}
           />
         </StyledConfigureTemplate>

+ 16 - 65
dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import _ from "lodash";
 import { useForm } from "react-hook-form";
@@ -6,10 +6,7 @@ import { withRouter, type RouteComponentProps } from "react-router";
 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 Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
 import Selector from "components/porter/Selector";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -17,20 +14,18 @@ import {
   dbFormValidator,
   type DatastoreTemplate,
   type DbFormData,
-  type EngineVersion,
   type ResourceOption,
 } from "lib/databases/types";
 
 import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import ConnectionInfo from "../shared/ConnectionInfo";
 import Resources from "../shared/Resources";
 import DatabaseForm, {
   AppearingErrorContainer,
-  Blur,
   CenterWrapper,
   DarkMatter,
   Div,
   Icon,
-  RevealButton,
   StyledConfigureTemplate,
 } from "./DatabaseForm";
 
@@ -39,8 +34,6 @@ type Props = RouteComponentProps & {
 };
 
 const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
-  const [isPasswordHidden, setIsPasswordHidden] = useState<boolean>(true);
-
   const dbForm = useForm<DbFormData>({
     resolver: zodResolver(dbFormValidator),
     reValidateMode: "onSubmit",
@@ -88,7 +81,7 @@ const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
               <>
                 <Text size={16}>Specify engine version</Text>
                 <Spacer y={0.5} />
-                <Selector<EngineVersion["name"]>
+                <Selector<string>
                   activeValue={watchEngine}
                   setActiveValue={(value) => {
                     setValue("config.engineVersion", value);
@@ -130,65 +123,23 @@ const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
                 />
               </>,
               <>
-                <Text size={16}>View credentials</Text>
+                <Text size={16}>Credentials</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  These credentials never leave your own cloud environment. You
-                  will be able to automatically import these credentials from
-                  any app.
+                  These credentials never leave your own cloud environment. Your
+                  app will use them to connect to this datastore.
                 </Text>
                 <Spacer height="20px" />
-                <Fieldset>
-                  <Text>Postgres DB name</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {watchDbName}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres username</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {watchDbUsername}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres password</Text>
-                  <Spacer y={0.5} />
-                  <Container row>
-                    {isPasswordHidden ? (
-                      <>
-                        <Blur>{watchDbPassword}</Blur>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => {
-                            setIsPasswordHidden(false);
-                          }}
-                        >
-                          Reveal
-                        </RevealButton>
-                      </>
-                    ) : (
-                      <>
-                        <ClickToCopy color="helper">
-                          {watchDbPassword}
-                        </ClickToCopy>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => {
-                            setIsPasswordHidden(true);
-                          }}
-                        >
-                          Hide
-                        </RevealButton>
-                      </>
-                    )}
-                  </Container>
-                </Fieldset>
+                <ConnectionInfo
+                  connectionInfo={{
+                    host: "(determined after creation)",
+                    port: 5432,
+                    password: watchDbPassword,
+                    username: watchDbUsername,
+                    database_name: watchDbName,
+                  }}
+                  type={template.type}
+                />
               </>,
             ]}
             currentStep={100}

+ 27 - 18
dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx

@@ -91,24 +91,33 @@ const ConnectAppsModal: React.FC<Props> = ({ closeModal, apps, onSubmit }) => {
     <Modal closeModal={closeModal}>
       <Text size={16}>Select apps</Text>
       <Spacer y={0.5} />
-      <SelectableAppList
-        appListItems={apps.map((a) => ({
-          app: a,
-          key: a.source.name,
-          onSelect: () => {
-            append(a.app_revision.app_instance_id);
-          },
-          onDeselect: () => {
-            remove(a.app_revision.app_instance_id);
-          },
-          isSelected: isSelected(a.app_revision.app_instance_id),
-        }))}
-      />
-      <Spacer y={1} />
-      <Text color="helper">
-        Click the button below to confirm the above selections. Newly connected
-        apps may take a few seconds to appear on the dashboard.
-      </Text>
+      {apps.length === 0 && (
+        <Text color="helper">
+          No apps are available to connect. Please create an app first.
+        </Text>
+      )}
+      {apps.length !== 0 && (
+        <>
+          <SelectableAppList
+            appListItems={apps.map((a) => ({
+              app: a,
+              key: a.source.name,
+              onSelect: () => {
+                append(a.app_revision.app_instance_id);
+              },
+              onDeselect: () => {
+                remove(a.app_revision.app_instance_id);
+              },
+              isSelected: isSelected(a.app_revision.app_instance_id),
+            }))}
+          />
+          <Spacer y={1} />
+          <Text color="helper">
+            Click the button below to confirm the above selections. Newly
+            connected apps may take a few seconds to appear on the dashboard.
+          </Text>
+        </>
+      )}
       <Spacer y={0.5} />
       <Button
         disabled={selectedAppInstanceIds.length === 0 || isSubmitting}

+ 116 - 0
dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx

@@ -0,0 +1,116 @@
+import React from "react";
+
+import ClickToCopy from "components/porter/ClickToCopy";
+import Container from "components/porter/Container";
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  DATASTORE_TYPE_ELASTICACHE,
+  type DatastoreConnectionInfo,
+  type DatastoreType,
+} from "lib/databases/types";
+
+import { Blur, RevealButton } from "../forms/DatabaseForm";
+
+type Props = {
+  connectionInfo: DatastoreConnectionInfo;
+  type: DatastoreType;
+};
+const ConnectionInfo: React.FC<Props> = ({ connectionInfo, type }) => {
+  const [isPasswordHidden, setIsPasswordHidden] = React.useState<boolean>(true);
+
+  return (
+    <Fieldset>
+      <Text>Host</Text>
+      <Spacer y={0.2} />
+      <ClickToCopy color="helper">{connectionInfo.host}</ClickToCopy>
+      <Spacer y={0.5} />
+      <Text>Port</Text>
+      <Spacer y={0.2} />
+      <ClickToCopy color="helper">{connectionInfo.port.toString()}</ClickToCopy>
+      <Spacer y={0.5} />
+      {type === DATASTORE_TYPE_ELASTICACHE ? (
+        <>
+          <Text>Auth token</Text>
+          <Spacer y={0.2} />
+          <Container row>
+            {isPasswordHidden ? (
+              <>
+                <Blur>{connectionInfo.password}</Blur>
+                <Spacer inline width="10px" />
+                <RevealButton
+                  onClick={() => {
+                    setIsPasswordHidden(false);
+                  }}
+                >
+                  Reveal
+                </RevealButton>
+              </>
+            ) : (
+              <>
+                <ClickToCopy color="helper">
+                  {connectionInfo.password}
+                </ClickToCopy>
+                <Spacer inline width="10px" />
+                <RevealButton
+                  onClick={() => {
+                    setIsPasswordHidden(true);
+                  }}
+                >
+                  Hide
+                </RevealButton>
+              </>
+            )}
+          </Container>
+        </>
+      ) : (
+        <>
+          <Text>Database name</Text>
+          <Spacer y={0.2} />
+          <ClickToCopy color="helper">
+            {connectionInfo.database_name}
+          </ClickToCopy>
+          <Spacer y={0.5} />
+          <Text>Username</Text>
+          <Spacer y={0.2} />
+          <ClickToCopy color="helper">{connectionInfo.username}</ClickToCopy>
+          <Spacer y={0.5} />
+          <Text>Password</Text>
+          <Spacer y={0.2} />
+          <Container row>
+            {isPasswordHidden ? (
+              <>
+                <Blur>{connectionInfo.password}</Blur>
+                <Spacer inline width="10px" />
+                <RevealButton
+                  onClick={() => {
+                    setIsPasswordHidden(false);
+                  }}
+                >
+                  Reveal
+                </RevealButton>
+              </>
+            ) : (
+              <>
+                <ClickToCopy color="helper">
+                  {connectionInfo.password}
+                </ClickToCopy>
+                <Spacer inline width="10px" />
+                <RevealButton
+                  onClick={() => {
+                    setIsPasswordHidden(true);
+                  }}
+                >
+                  Hide
+                </RevealButton>
+              </>
+            )}
+          </Container>
+        </>
+      )}
+    </Fieldset>
+  );
+};
+
+export default ConnectionInfo;

+ 20 - 84
dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx

@@ -1,9 +1,8 @@
-import React, { useMemo } from "react";
+import React from "react";
 import styled from "styled-components";
 
 import CopyToClipboard from "components/CopyToClipboard";
 import Container from "components/porter/Container";
-import Input from "components/porter/Input";
 import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -11,91 +10,43 @@ import Text from "components/porter/Text";
 import copy from "assets/copy-left.svg";
 
 import { useDatastoreContext } from "../DatabaseContextProvider";
+import ConnectionInfo from "../shared/ConnectionInfo";
 
 const ConnectTab: React.FC = () => {
   const { datastore } = useDatastoreContext();
 
-  const keyValues = useMemo(() => {
-    const datastoreEnvEntries = Object.entries(datastore.env?.variables ?? {});
-    const datastoreSecretEntries = Object.entries(
-      datastore.env?.secret_variables ?? {}
-    );
-    const keyValues = [
-      ...datastoreEnvEntries.map(([key, value]) => ({
-        key,
-        value,
-        secret: false,
-      })),
-      ...datastoreSecretEntries.map(([key, value]) => ({
-        key,
-        value,
-        secret: true,
-      })),
-    ];
-
-    return keyValues;
-  }, [datastore]);
   return (
     <CredentialsTabContainer>
       <Container row>
         <Text size={16}>Application connection</Text>
       </Container>
-      {keyValues.length !== 0 && (
+      {datastore.credential.host !== "" && (
         <>
           <Spacer y={0.5} />
           <Text color="helper">
-            Once an app is connected to this datastore, it has access to its
-            credentials through the following environment variables:
+            All apps deployed in your cluster can access this datastore using
+            the following credentials:
+          </Text>
+          <Spacer y={0.5} />
+          <ConnectionInfo
+            connectionInfo={datastore.credential}
+            type={datastore.template.type}
+          />
+          <Spacer y={0.5} />
+          <Text color="warner">
+            For security, access to the datastore is restricted - connection
+            attempts from outside the cluster will not succeed.
           </Text>
-          <StyledInputArray>
-            {keyValues.map(
-              (
-                entry: { key: string; value: string; secret: boolean },
-                i: number
-              ) => {
-                return (
-                  <InputWrapper key={i}>
-                    <Input
-                      placeholder="ex: key"
-                      width="120px"
-                      value={entry.key}
-                      setValue={() => ({})}
-                      disabled={entry.secret}
-                      disabledTooltip={"Stored as a secret on your cluster"}
-                    />
-                    <Spacer x={0.5} inline />
-                    <Input
-                      placeholder="ex: key"
-                      width="500px"
-                      value={entry.value}
-                      setValue={() => ({})}
-                      disabled={entry.secret}
-                      disabledTooltip={"Stored as a secret on your cluster"}
-                    />
-                    {!entry.secret && (
-                      <>
-                        <Spacer x={0.5} inline />
-                        <CopyToClipboard text={entry.value}>
-                          <CopyIcon src={copy} alt="copy" />
-                        </CopyToClipboard>
-                      </>
-                    )}
-                  </InputWrapper>
-                );
-              }
-            )}
-          </StyledInputArray>
           <Spacer y={0.5} />
           <Text color="helper">
             The datastore client of your application should use these
-            environment variables to create a connection.
+            credentials to create a connection.
           </Text>
           {datastore.template.type.name === "ELASTICACHE" && (
             <>
               <Spacer y={0.5} />
               <Text color="warner">
-                In order for connection to succeed, your datastore client must
-                connect via SSL.
+                Your datastore client must connect via SSL.
               </Text>
             </>
           )}
@@ -104,19 +55,15 @@ const ConnectTab: React.FC = () => {
       <Spacer y={1} />
       <Text size={16}>Local connection</Text>
       <Spacer y={0.5} />
-      <Text color="warner">
-        The credentials above will only work for apps running on your cluster.
-      </Text>
-      <Spacer y={0.5} />
       <Text color="helper">
-        However, if you have authenticated via the{" "}
+        For local connection, you can create a temporary, secure tunnel to this
+        datastore using the{" "}
         <Link
           to="https://docs.porter.run/standard/cli/command-reference/porter-datastore-connect"
           target="_blank"
         >
           <Text>Porter CLI</Text>
-        </Link>{" "}
-        , you can create a secure tunnel to this datastore to connect locally:
+        </Link>
       </Text>
       <Spacer y={0.5} />
       <IdContainer>
@@ -168,14 +115,3 @@ const CopyIcon = styled.img`
 const Code = styled.span`
   font-family: monospace;
 `;
-
-const StyledInputArray = styled.div`
-  margin-bottom: 15px;
-  margin-top: 22px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-top: 5px;
-`;

+ 11 - 0
dashboard/src/shared/api.tsx

@@ -2803,6 +2803,16 @@ const getDatastore = baseApi<
   return `/api/projects/${project_id}/datastores/${datastore_name}`;
 });
 
+const getDatastoreCredential = baseApi<
+  {},
+  {
+    project_id: number;
+    datastore_name: string;
+  }
+>("GET", ({ project_id, datastore_name }) => {
+  return `/api/projects/${project_id}/datastores/${datastore_name}/credential`;
+});
+
 const updateDatastore = baseApi<
   {
     name: string;
@@ -3725,6 +3735,7 @@ export default {
   getDatastores,
   listDatastores,
   getDatastore,
+  getDatastoreCredential,
   updateDatastore,
   deleteDatastore,
   getPreviousLogsForContainer,

+ 1 - 1
go.mod

@@ -84,7 +84,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.124
+	github.com/porter-dev/api-contracts v0.2.125
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1525,8 +1525,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.124 h1:0ChXriR88KanBMMJfDWIabEvPqt9eLsmOScDbuJucBQ=
-github.com/porter-dev/api-contracts v0.2.124/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.125 h1:IBZkLyOiqD6WE6SGCcUGbIcqcfgkizuTuotmkRfF2ak=
+github.com/porter-dev/api-contracts v0.2.125/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 11 - 0
internal/datastore/datastore.go

@@ -27,6 +27,17 @@ type Datastore struct {
 	CloudProvider string `json:"cloud_provider"`
 	// CloudProviderCredentialIdentifier is the cloud provider credential identifier associated with the datastore
 	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
+	// Credential is the credential used for connecting to the datastore
+	Credential Credential `json:"credential"`
 	// B64Proto is the base64 encoded datastore proto. Note that this is only populated for datastores created with the new cloud contract flow
 	B64Proto string `json:"b64_proto"`
 }
+
+// Credential has all information about connecting to a datastore
+type Credential struct {
+	Host         string `json:"host"`
+	Port         int    `json:"port"`
+	Username     string `json:"username"`
+	Password     string `json:"password"`
+	DatabaseName string `json:"database_name"`
+}