From 82db8f4afe9000be2b06cc8e4f1d34b0b2d91097 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Tue, 14 May 2024 14:20:34 -0400 Subject: [PATCH] upstash frontend (#4641) --- api/server/handlers/datastore/update.go | 3 + api/server/handlers/neon_integration/list.go | 2 +- api/server/handlers/oauth_callback/upstash.go | 1 + .../handlers/upstash_integration/list.go | 69 ++++++++++++++++++ api/server/router/project.go | 28 ++++++++ dashboard/src/assets/upstash.svg | 15 ++++ dashboard/src/lib/databases/types.ts | 10 +++ dashboard/src/lib/hooks/useDatastore.ts | 21 +++++- dashboard/src/lib/hooks/useUpstash.ts | 41 +++++++++++ dashboard/src/lib/upstash/types.ts | 8 +++ .../database-dashboard/DatabaseHeader.tsx | 40 ++++++++++- .../DatastoreFormContextProvider.tsx | 26 ++++++- .../DatastoreProvisioningIndicator.tsx | 11 ++- .../main/home/database-dashboard/constants.ts | 38 ++++++++++ .../forms/SandboxDatastoreForm.tsx | 43 +++++------ .../shared/ConnectionInfo.tsx | 6 ++ .../shared/NeonIntegrationModal.tsx | 46 +++++++++++- .../database-dashboard/tabs/SettingsTab.tsx | 11 +-- dashboard/src/shared/api.tsx | 16 ++++- go.mod | 2 +- go.sum | 2 + internal/repository/gorm/upstash.go | 72 +++++++++++++++++++ internal/repository/test/upstash.go | 4 ++ internal/repository/upstash.go | 2 + 24 files changed, 479 insertions(+), 38 deletions(-) create mode 100644 api/server/handlers/upstash_integration/list.go create mode 100644 dashboard/src/assets/upstash.svg create mode 100644 dashboard/src/lib/hooks/useUpstash.ts create mode 100644 dashboard/src/lib/upstash/types.ts diff --git a/api/server/handlers/datastore/update.go b/api/server/handlers/datastore/update.go index 7a2a335387..7fc92d1455 100644 --- a/api/server/handlers/datastore/update.go +++ b/api/server/handlers/datastore/update.go @@ -190,6 +190,9 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques case "NEON": datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_NEON datastoreProto.KindValues = &porterv1.ManagedDatastore_NeonKind{} + case "UPSTASH": + datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_UPSTASH + datastoreProto.KindValues = &porterv1.ManagedDatastore_UpstashKind{} default: err = telemetry.Error(ctx, span, nil, "invalid datastore type") h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) diff --git a/api/server/handlers/neon_integration/list.go b/api/server/handlers/neon_integration/list.go index e250e6e587..f6f1dddc32 100644 --- a/api/server/handlers/neon_integration/list.go +++ b/api/server/handlers/neon_integration/list.go @@ -13,7 +13,7 @@ import ( "github.com/porter-dev/porter/internal/telemetry" ) -// ListNeonIntegrationsHandler is a struct for listing all noen integrations for a given project +// ListNeonIntegrationsHandler is a struct for listing all neon integrations for a given project type ListNeonIntegrationsHandler struct { handlers.PorterHandlerReadWriter } diff --git a/api/server/handlers/oauth_callback/upstash.go b/api/server/handlers/oauth_callback/upstash.go index abe5b22ad9..828b688150 100644 --- a/api/server/handlers/oauth_callback/upstash.go +++ b/api/server/handlers/oauth_callback/upstash.go @@ -131,6 +131,7 @@ func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.R oauthInt := integrations.UpstashIntegration{ SharedOAuthModel: integrations.SharedOAuthModel{ + ClientID: []byte(p.Config().UpstashConf.ClientID), AccessToken: []byte(token.AccessToken), RefreshToken: []byte(token.RefreshToken), Expiry: token.Expiry, diff --git a/api/server/handlers/upstash_integration/list.go b/api/server/handlers/upstash_integration/list.go new file mode 100644 index 0000000000..20c4f79ad8 --- /dev/null +++ b/api/server/handlers/upstash_integration/list.go @@ -0,0 +1,69 @@ +package upstash_integration + +import ( + "net/http" + "time" + + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/apierrors" + "github.com/porter-dev/porter/api/server/shared/config" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// ListUpstashIntegrationsHandler is a struct for listing all upstash integrations for a given project +type ListUpstashIntegrationsHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewListUpstashIntegrationsHandler constructs a ListUpstashIntegrationsHandler +func NewListUpstashIntegrationsHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *ListUpstashIntegrationsHandler { + return &ListUpstashIntegrationsHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// UpstashIntegration describes a upstash integration +type UpstashIntegration struct { + CreatedAt time.Time `json:"created_at"` +} + +// ListUpstashIntegrationsResponse describes the list upstash integrations response body +type ListUpstashIntegrationsResponse struct { + // Integrations is a list of upstash integrations + Integrations []UpstashIntegration `json:"integrations"` +} + +// ServeHTTP returns a list of upstash integrations associated with the specified project +func (h *ListUpstashIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-upstash-integrations") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + + resp := ListUpstashIntegrationsResponse{} + integrationList := make([]UpstashIntegration, 0) + + integrations, err := h.Repo().UpstashIntegration().Integrations(ctx, project.ID) + if err != nil { + err := telemetry.Error(ctx, span, err, "error getting datastores") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + for _, int := range integrations { + integrationList = append(integrationList, UpstashIntegration{ + CreatedAt: int.CreatedAt, + }) + } + + resp.Integrations = integrationList + + h.WriteResult(w, r, resp) +} diff --git a/api/server/router/project.go b/api/server/router/project.go index 2b30b03103..5a922f33bb 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -5,6 +5,7 @@ import ( "github.com/porter-dev/porter/api/server/handlers/cloud_provider" "github.com/porter-dev/porter/api/server/handlers/neon_integration" + "github.com/porter-dev/porter/api/server/handlers/upstash_integration" "github.com/porter-dev/porter/api/server/handlers/deployment_target" @@ -2049,5 +2050,32 @@ func getProjectRoutes( Router: r, }) + // GET /api/projects/{project_id}/upstash-integrations -> apiContract.NewListUpstashIntegrationsHandler + listUpstashIntegrationsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: relPath + "/upstash-integrations", + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + listUpstashIntegrationsHandler := upstash_integration.NewListUpstashIntegrationsHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + routes = append(routes, &router.Route{ + Endpoint: listUpstashIntegrationsEndpoint, + Handler: listUpstashIntegrationsHandler, + Router: r, + }) + return routes, newPath } diff --git a/dashboard/src/assets/upstash.svg b/dashboard/src/assets/upstash.svg new file mode 100644 index 0000000000..e928467552 --- /dev/null +++ b/dashboard/src/assets/upstash.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dashboard/src/lib/databases/types.ts b/dashboard/src/lib/databases/types.ts index 0d49a168a3..84fb81aa13 100644 --- a/dashboard/src/lib/databases/types.ts +++ b/dashboard/src/lib/databases/types.ts @@ -37,6 +37,7 @@ const datastoreTypeValidator = z.enum([ "MANAGED_REDIS", "MANAGED_POSTGRES", "NEON", + "UPSTASH", ]); const datastoreEngineValidator = z.enum([ "UNKNOWN", @@ -114,6 +115,10 @@ export const DATASTORE_TYPE_NEON: DatastoreType = { name: "NEON" as const, displayName: "Neon", }; +export const DATASTORE_TYPE_UPSTASH: DatastoreType = { + name: "UPSTASH" as const, + displayName: "Upstash", +}; export type DatastoreState = { state: z.infer["status"]; @@ -334,6 +339,10 @@ const neonValidator = z.object({ type: z.literal("neon"), }); +const upstashValidator = z.object({ + type: z.literal("upstash"), +}); + export const dbFormValidator = z.object({ name: z .string() @@ -355,6 +364,7 @@ export const dbFormValidator = z.object({ managedRedisConfigValidator, managedPostgresConfigValidator, neonValidator, + upstashValidator, ]), clusterId: z.number(), }); diff --git a/dashboard/src/lib/hooks/useDatastore.ts b/dashboard/src/lib/hooks/useDatastore.ts index 9031ca5a37..2401aab029 100644 --- a/dashboard/src/lib/hooks/useDatastore.ts +++ b/dashboard/src/lib/hooks/useDatastore.ts @@ -21,7 +21,13 @@ type DatastoreHook = { }; type CreateDatastoreInput = { name: string; - type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS" | "NEON"; + type: + | "RDS" + | "ELASTICACHE" + | "MANAGED-POSTGRES" + | "MANAGED-REDIS" + | "NEON" + | "UPSTASH"; engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS"; values: object; }; @@ -147,6 +153,19 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => { engine: "POSTGRES", }) ) + .with( + { config: { type: "upstash" } }, + (values): CreateDatastoreInput => ({ + name: values.name, + values: { + config: { + name: values.name, + }, + }, + type: "UPSTASH", + engine: "REDIS", + }) + ) .exhaustive(); }; diff --git a/dashboard/src/lib/hooks/useUpstash.ts b/dashboard/src/lib/hooks/useUpstash.ts new file mode 100644 index 0000000000..5945a323cd --- /dev/null +++ b/dashboard/src/lib/hooks/useUpstash.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { + upstashIntegrationValidator, + type ClientUpstashIntegration, +} from "lib/upstash/types"; + +import api from "shared/api"; + +type TUseUpstash = { + getUpstashIntegrations: ({ + projectId, + }: { + projectId: number; + }) => Promise; +}; +export const useUpstash = (): TUseUpstash => { + const getUpstashIntegrations = async ({ + projectId, + }: { + projectId: number; + }): Promise => { + const response = await api.getUpstashIntegrations( + "", + {}, + { + projectId, + } + ); + + const results = await z + .object({ integrations: z.array(upstashIntegrationValidator) }) + .parseAsync(response.data); + + return results.integrations; + }; + + return { + getUpstashIntegrations, + }; +}; diff --git a/dashboard/src/lib/upstash/types.ts b/dashboard/src/lib/upstash/types.ts new file mode 100644 index 0000000000..6aa578ec27 --- /dev/null +++ b/dashboard/src/lib/upstash/types.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const upstashIntegrationValidator = z.object({ + created_at: z.string(), +}); +export type ClientUpstashIntegration = z.infer< + typeof upstashIntegrationValidator +>; diff --git a/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx b/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx index 106e63603b..75d6ee90f3 100644 --- a/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx +++ b/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useContext, useState } from "react"; import styled from "styled-components"; import { match } from "ts-pattern"; @@ -7,15 +7,23 @@ import Icon from "components/porter/Icon"; import Spacer from "components/porter/Spacer"; import StatusDot from "components/porter/StatusDot"; import Text from "components/porter/Text"; +import Tooltip from "components/porter/Tooltip"; +import { Context } from "shared/Context"; import { readableDate } from "shared/string_utils"; +import trash from "assets/trash.png"; import { useDatastoreContext } from "./DatabaseContextProvider"; +import { DeleteDatastoreModal } from "./tabs/SettingsTab"; import EngineTag from "./tags/EngineTag"; const DatabaseHeader: React.FC = () => { const { datastore } = useDatastoreContext(); + const [showDeleteDatastoreModal, setShowDeleteDatastoreModal] = + useState(false); + const { user } = useContext(Context); + return ( <> @@ -28,6 +36,29 @@ const DatabaseHeader: React.FC = () => { + {user?.isPorterUser && ( + <> + + + Delete this datastore and all of its resources (only + visible to Porter operators). + + } + position={"right"} + > +
{ + setShowDeleteDatastoreModal(true); + }} + > + +
+
+ + )}
{match(datastore.status) .with("AVAILABLE", () => ( @@ -51,6 +82,13 @@ const DatabaseHeader: React.FC = () => { + {showDeleteDatastoreModal && ( + { + setShowDeleteDatastoreModal(false); + }} + /> + )} ); }; diff --git a/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx b/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx index d2cba34e90..34f3838a3f 100644 --- a/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx +++ b/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx @@ -12,10 +12,14 @@ import { useDatastoreList } from "lib/hooks/useDatabaseList"; import { useDatastore } from "lib/hooks/useDatastore"; import { useIntercom } from "lib/hooks/useIntercom"; import { useNeon } from "lib/hooks/useNeon"; +import { useUpstash } from "lib/hooks/useUpstash"; import { Context } from "shared/Context"; -import NeonIntegrationModal from "./shared/NeonIntegrationModal"; +import { + NeonIntegrationModal, + UpstashIntegrationModal, +} from "./shared/NeonIntegrationModal"; // todo(ianedwards): refactor button to use more predictable state export type UpdateDatastoreButtonProps = { @@ -53,8 +57,11 @@ const DatastoreFormContextProvider: React.FC< const [updateDatastoreError, setUpdateDatastoreError] = useState(""); const { getNeonIntegrations } = useNeon(); + const { getUpstashIntegrations } = useUpstash(); const [showNeonIntegrationModal, setShowNeonIntegrationModal] = useState(false); + const [showUpstashIntegrationModal, setShowUpstashIntegrationModal] = + useState(false); const { showIntercomWithMessage } = useIntercom(); @@ -117,6 +124,16 @@ const DatastoreFormContextProvider: React.FC< return; } } + if (data.config.type === "upstash") { + const integrations = await getUpstashIntegrations({ + projectId: currentProject.id, + }); + if (integrations.length === 0) { + setShowUpstashIntegrationModal(true); + return; + } + } + await createDatastore(data); history.push(`/datastores/${data.name}`); } catch (err) { @@ -154,6 +171,13 @@ const DatastoreFormContextProvider: React.FC< }} /> )} + {showUpstashIntegrationModal && ( + { + setShowUpstashIntegrationModal(false); + }} + /> + )} ); }; diff --git a/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx b/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx index 30c8783ef8..6e114a8dea 100644 --- a/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx +++ b/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx @@ -2,7 +2,10 @@ import React, { useMemo } from "react"; import StatusBar from "components/porter/StatusBar"; -import { DATASTORE_TEMPLATE_NEON } from "./constants"; +import { + DATASTORE_TEMPLATE_NEON, + DATASTORE_TEMPLATE_UPSTASH, +} from "./constants"; import { useDatastoreContext } from "./DatabaseContextProvider"; const DatastoreProvisioningIndicator: React.FC = () => { @@ -40,9 +43,13 @@ const DatastoreProvisioningIndicator: React.FC = () => { return { percentCompleted, title, titleDescriptor, isCreating }; }, [datastore]); + // TODO: refactor this so the template has the setup/deletion time const subtitle = useMemo(() => { return `${isCreating ? "Setup" : "Deletion"} can take up to ${ - datastore.template === DATASTORE_TEMPLATE_NEON ? 5 : 20 + datastore.template === DATASTORE_TEMPLATE_NEON || + datastore.template === DATASTORE_TEMPLATE_UPSTASH + ? 5 + : 20 } minutes. You can close this window and come back later.`; }, [datastore]); diff --git a/dashboard/src/main/home/database-dashboard/constants.ts b/dashboard/src/main/home/database-dashboard/constants.ts index 6aed92c2e8..e12394850f 100644 --- a/dashboard/src/main/home/database-dashboard/constants.ts +++ b/dashboard/src/main/home/database-dashboard/constants.ts @@ -15,6 +15,7 @@ import { DATASTORE_TYPE_MANAGED_REDIS, DATASTORE_TYPE_NEON, DATASTORE_TYPE_RDS, + DATASTORE_TYPE_UPSTASH, type DatastoreEngine, type DatastoreTemplate, } from "lib/databases/types"; @@ -25,6 +26,7 @@ import infra from "assets/cluster.svg"; import neon from "assets/neon.svg"; import postgresql from "assets/postgresql.svg"; import redis from "assets/redis.svg"; +import upstash from "assets/upstash.svg"; import ConfigurationTab from "./tabs/ConfigurationTab"; import ConnectTab from "./tabs/ConnectTab"; @@ -418,6 +420,41 @@ export const DATASTORE_TEMPLATE_NEON: DatastoreTemplate = Object.freeze({ ], }); +export const DATASTORE_TEMPLATE_UPSTASH: DatastoreTemplate = Object.freeze({ + name: "Upstash", + displayName: "Upstash", + highLevelType: DATASTORE_ENGINE_REDIS, + type: DATASTORE_TYPE_UPSTASH, + engine: DATASTORE_ENGINE_REDIS, + supportedEngineVersions: [], + icon: upstash as string, + description: + "A fully managed, serverless data store optimized for Redis. Upstash separates storage and compute to deliver auto-scaling, on-demand databases, and per-request pricing with a focus on low latency and high availability.", + disabled: true, + instanceTiers: [], + creationStateProgression: [ + DATASTORE_STATE_CREATING, + DATASTORE_STATE_AVAILABLE, + ], + deletionStateProgression: [ + DATASTORE_STATE_AWAITING_DELETION, + DATASTORE_STATE_DELETING_RECORD, + DATASTORE_STATE_DELETED, + ], + tabs: [ + { + name: "connectivity", + displayName: "Connectivity", + component: PublicDatastoreConnectTab, + }, + { + name: "settings", + displayName: "Settings", + component: SettingsTab, + }, + ], +}); + export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [ DATASTORE_TEMPLATE_AWS_RDS, DATASTORE_TEMPLATE_AWS_AURORA, @@ -425,4 +462,5 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [ DATASTORE_TEMPLATE_MANAGED_POSTGRES, DATASTORE_TEMPLATE_MANAGED_REDIS, DATASTORE_TEMPLATE_NEON, + DATASTORE_TEMPLATE_UPSTASH, ]; diff --git a/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx b/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx index 70590cf62d..b802fc9790 100644 --- a/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx +++ b/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx @@ -24,6 +24,7 @@ import { DATASTORE_ENGINE_POSTGRES, DATASTORE_ENGINE_REDIS, DATASTORE_TEMPLATE_NEON, + DATASTORE_TEMPLATE_UPSTASH, SUPPORTED_DATASTORE_TEMPLATES, } from "../constants"; import { useDatastoreFormContext } from "../DatastoreFormContextProvider"; @@ -48,21 +49,14 @@ const SandboxDatastoreForm: React.FC = () => { const { updateDatastoreButtonProps } = useDatastoreFormContext(); const availableEngines: BlockSelectOption[] = useMemo(() => { - return [ - DATASTORE_ENGINE_POSTGRES, - { - ...DATASTORE_ENGINE_REDIS, - disabledOpts: { - tooltipText: "Coming soon!", - }, - }, - ]; + return [DATASTORE_ENGINE_POSTGRES, DATASTORE_ENGINE_REDIS]; }, [watchClusterId]); const availableHostTypes: BlockSelectOption[] = useMemo(() => { - const options = [DATASTORE_TEMPLATE_NEON].filter( - (t) => t.highLevelType.name === watchEngine - ); + const options = [ + DATASTORE_TEMPLATE_NEON, + DATASTORE_TEMPLATE_UPSTASH, + ].filter((t) => t.highLevelType.name === watchEngine); return options; }, [watchEngine]); @@ -167,14 +161,23 @@ const SandboxDatastoreForm: React.FC = () => { return; } setTemplate(templateMatch); - match(templateMatch).with( - { - name: DATASTORE_TEMPLATE_NEON.name, - }, - () => { - setValue("config.type", "neon"); - } - ); + match(templateMatch) + .with( + { + name: DATASTORE_TEMPLATE_NEON.name, + }, + () => { + setValue("config.type", "neon"); + } + ) + .with( + { + name: DATASTORE_TEMPLATE_UPSTASH.name, + }, + () => { + setValue("config.type", "upstash"); + } + ); setCurrentStep(4); }} /> diff --git a/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx b/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx index 750da78703..0e64e6d1bb 100644 --- a/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx +++ b/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx @@ -13,6 +13,7 @@ import { DATASTORE_TYPE_MANAGED_REDIS, DATASTORE_TYPE_NEON, DATASTORE_TYPE_RDS, + DATASTORE_TYPE_UPSTASH, type DatastoreConnectionInfo, type DatastoreTemplate, } from "lib/databases/types"; @@ -39,6 +40,11 @@ const ConnectionInfo: React.FC = ({ connectionInfo, template }) => { () => `rediss://:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0?ssl_cert_reqs=CERT_REQUIRED` ) + .with( + { type: DATASTORE_TYPE_UPSTASH }, + () => + `rediss://default:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0` + ) .with( { type: DATASTORE_TYPE_MANAGED_REDIS }, () => diff --git a/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx b/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx index 3c0118efb8..ec5199401d 100644 --- a/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx +++ b/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx @@ -7,6 +7,7 @@ import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { useAuthWindow } from "lib/hooks/useAuthWindow"; import { useNeon } from "lib/hooks/useNeon"; +import { useUpstash } from "lib/hooks/useUpstash"; import { useDatastoreFormContext } from "../DatastoreFormContextProvider"; @@ -14,7 +15,7 @@ type Props = { onClose: () => void; }; -const NeonIntegrationModal: React.FC = ({ onClose }) => { +export const NeonIntegrationModal: React.FC = ({ onClose }) => { const { projectId } = useDatastoreFormContext(); const { getNeonIntegrations } = useNeon(); const { openAuthWindow } = useAuthWindow({ @@ -57,4 +58,45 @@ const NeonIntegrationModal: React.FC = ({ onClose }) => { ); }; -export default NeonIntegrationModal; +export const UpstashIntegrationModal: React.FC = ({ onClose }) => { + const { projectId } = useDatastoreFormContext(); + const { getUpstashIntegrations } = useUpstash(); + const { openAuthWindow } = useAuthWindow({ + authUrl: `/api/projects/${projectId}/oauth/upstash`, + }); + + const upstashIntegrationsResp = useQuery( + ["getUpstashIntegrations", projectId], + async () => { + const integrations = await getUpstashIntegrations({ + projectId, + }); + return integrations; + }, + { + enabled: !!projectId, + refetchInterval: 1000, + } + ); + useEffect(() => { + if ( + upstashIntegrationsResp.isSuccess && + upstashIntegrationsResp.data.length > 0 + ) { + onClose(); + } + }, [upstashIntegrationsResp]); + + return ( + + Integrate Upstash + + + To continue, you must authenticate with Upstash.{" "} + + Authorize Porter to create Upstash datastores on your behalf + + + + ); +}; diff --git a/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx b/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx index ba10549d46..d2cd77ed26 100644 --- a/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx +++ b/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx @@ -22,7 +22,6 @@ const SettingsTab: React.FC = () => { useState(false); const { datastore } = useDatastoreContext(); - const { deleteDatastore } = useDatastore(); return (
@@ -49,9 +48,6 @@ const SettingsTab: React.FC = () => { onClose={() => { setShowDeleteDatastoreModal(false); }} - onSubmit={async () => { - await deleteDatastore(datastore.name); - }} /> )}
@@ -61,15 +57,14 @@ const SettingsTab: React.FC = () => { export default SettingsTab; type DeleteDatastoreModalProps = { - onSubmit: () => Promise; onClose: () => void; }; -const DeleteDatastoreModal: React.FC = ({ - onSubmit, +export const DeleteDatastoreModal: React.FC = ({ onClose, }) => { const { datastore } = useDatastoreContext(); + const { deleteDatastore } = useDatastore(); const [inputtedDatastoreName, setInputtedDatastoreName] = useState(""); @@ -79,7 +74,7 @@ const DeleteDatastoreModal: React.FC = ({ const confirmDeletion = async (): Promise => { setIsSubmitting(true); try { - await onSubmit(); + await deleteDatastore(datastore.name); onClose(); } catch (err) { setDeleteDatastoreError( diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index a8f19ccc25..da73c5a746 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -2068,6 +2068,13 @@ const getNeonIntegrations = baseApi<{}, { projectId: number }>( } ); +const getUpstashIntegrations = baseApi<{}, { projectId: number }>( + "GET", + ({ projectId }) => { + return `/api/projects/${projectId}/upstash-integrations`; + } +); + const getRevisions = baseApi< {}, { id: number; cluster_id: number; namespace: string; name: string } @@ -2889,7 +2896,13 @@ const getDatastoreCredential = baseApi< const updateDatastore = baseApi< { name: string; - type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS" | "NEON"; + type: + | "RDS" + | "ELASTICACHE" + | "MANAGED-POSTGRES" + | "MANAGED-REDIS" + | "NEON" + | "UPSTASH"; engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS"; values: any; @@ -3895,6 +3908,7 @@ export default { getRepoIntegrations, getSlackIntegrations, getNeonIntegrations, + getUpstashIntegrations, getRepos, getRevisions, getTemplateInfo, diff --git a/go.mod b/go.mod index 2f2a217e05..353d86277b 100644 --- a/go.mod +++ b/go.mod @@ -88,7 +88,7 @@ require ( github.com/nats-io/nats.go v1.24.0 github.com/open-policy-agent/opa v0.44.0 github.com/ory/client-go v1.9.0 - github.com/porter-dev/api-contracts v0.2.159 + github.com/porter-dev/api-contracts v0.2.161 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 diff --git a/go.sum b/go.sum index f5e65fd22f..a029f74ff8 100644 --- a/go.sum +++ b/go.sum @@ -1565,6 +1565,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw= github.com/porter-dev/api-contracts v0.2.159 h1:Ze4K0rm8p6sRMxaFW4Nb3dJuzz4NEMQ+UMXMtOKKRQ4= github.com/porter-dev/api-contracts v0.2.159/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= +github.com/porter-dev/api-contracts v0.2.161 h1:kf1ZcS1032eLabBzjwDs9SVcecXwUxJ2mJUkRl9C8jk= +github.com/porter-dev/api-contracts v0.2.161/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= 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= diff --git a/internal/repository/gorm/upstash.go b/internal/repository/gorm/upstash.go index 2e39b5b67a..34b3b04192 100644 --- a/internal/repository/gorm/upstash.go +++ b/internal/repository/gorm/upstash.go @@ -42,6 +42,31 @@ func (repo *UpstashIntegrationRepository) Insert( return created, nil } +// Integrations returns all upstash integrations for a given project +func (repo *UpstashIntegrationRepository) Integrations( + ctx context.Context, projectID uint, +) ([]ints.UpstashIntegration, error) { + ctx, span := telemetry.NewSpan(ctx, "gorm-list-upstash-integrations") + defer span.End() + + var integrations []ints.UpstashIntegration + + if err := repo.db.Where("project_id = ?", projectID).Find(&integrations).Error; err != nil { + return integrations, telemetry.Error(ctx, span, err, "failed to list upstash integrations") + } + + for i, integration := range integrations { + decrypted, err := repo.DecryptUpstashIntegration(integration, repo.key) + if err != nil { + return integrations, telemetry.Error(ctx, span, err, "failed to decrypt") + } + + integrations[i] = decrypted + } + + return integrations, nil +} + // EncryptUpstashIntegration will encrypt the upstash integration data before // writing to the DB func (repo *UpstashIntegrationRepository) EncryptUpstashIntegration( @@ -88,3 +113,50 @@ func (repo *UpstashIntegrationRepository) EncryptUpstashIntegration( return encrypted, nil } + +// DecryptUpstashIntegration will decrypt the upstash integration data before +// returning it from the DB +func (repo *UpstashIntegrationRepository) DecryptUpstashIntegration( + upstashInt ints.UpstashIntegration, + key *[32]byte, +) (ints.UpstashIntegration, error) { + decrypted := upstashInt + + if len(decrypted.ClientID) > 0 { + plaintext, err := encryption.Decrypt(decrypted.ClientID, key) + if err != nil { + return decrypted, err + } + + decrypted.ClientID = plaintext + } + + if len(decrypted.AccessToken) > 0 { + plaintext, err := encryption.Decrypt(decrypted.AccessToken, key) + if err != nil { + return decrypted, err + } + + decrypted.AccessToken = plaintext + } + + if len(decrypted.RefreshToken) > 0 { + plaintext, err := encryption.Decrypt(decrypted.RefreshToken, key) + if err != nil { + return decrypted, err + } + + decrypted.RefreshToken = plaintext + } + + if len(decrypted.DeveloperApiKey) > 0 { + plaintext, err := encryption.Decrypt(decrypted.DeveloperApiKey, key) + if err != nil { + return decrypted, err + } + + decrypted.DeveloperApiKey = plaintext + } + + return decrypted, nil +} diff --git a/internal/repository/test/upstash.go b/internal/repository/test/upstash.go index ed189fe39f..295238f0e0 100644 --- a/internal/repository/test/upstash.go +++ b/internal/repository/test/upstash.go @@ -16,3 +16,7 @@ func NewUpstashIntegrationRepository(canQuery bool) repository.UpstashIntegratio func (s *UpstashIntegrationRepository) Insert(ctx context.Context, upstashInt ints.UpstashIntegration) (ints.UpstashIntegration, error) { panic("not implemented") // TODO: Implement } + +func (s *UpstashIntegrationRepository) Integrations(ctx context.Context, projectID uint) ([]ints.UpstashIntegration, error) { + panic("not implemented") // TODO: Implement +} diff --git a/internal/repository/upstash.go b/internal/repository/upstash.go index 18b4629579..56fc00ef16 100644 --- a/internal/repository/upstash.go +++ b/internal/repository/upstash.go @@ -10,4 +10,6 @@ import ( type UpstashIntegrationRepository interface { // Insert creates a new upstash integration Insert(ctx context.Context, upstashInt ints.UpstashIntegration) (ints.UpstashIntegration, error) + // Integrations returns all upstash integrations belonging to a project + Integrations(ctx context.Context, projectID uint) ([]ints.UpstashIntegration, error) }