diff --git a/apps/studio/components/grid/components/grid/GridError.tsx b/apps/studio/components/grid/components/grid/GridError.tsx index d18894c8208..368a2d1d456 100644 --- a/apps/studio/components/grid/components/grid/GridError.tsx +++ b/apps/studio/components/grid/components/grid/GridError.tsx @@ -1,13 +1,14 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' +import { ChevronDown } from 'lucide-react' import { useCallback } from 'react' -import { Button } from 'ui' +import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' import { Admonition } from 'ui-patterns' import { isFilterRelatedError } from './GridError.utils' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' import { useTableSort } from '@/components/grid/hooks/useTableSort' -import AlertError from '@/components/ui/AlertError' +import { AlertError } from '@/components/ui/AlertError' import { HighCostError } from '@/components/ui/HighQueryCost' import { InlineLink } from '@/components/ui/InlineLink' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -53,6 +54,12 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const isForeignTableMissingVaultKeyError = isForeignTable && error?.message?.includes('query vault failed') + const isIcebergUnauthorizedError = + isForeignTable && + error?.message.includes('iceberg error') && + error?.message.includes('403 Forbidden') && + error?.message.includes('Invalid Compact JWS') + const hasActiveFilters = filters.length > 0 const hasFilterRelatedError = hasActiveFilters && isFilterRelatedError(error?.message) @@ -79,6 +86,8 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { return } else if (isInvalidOrderingOperatorError) { return + } else if (isIcebergUnauthorizedError) { + return } return @@ -165,6 +174,30 @@ const InvalidOrderingOperatorError = ({ error }: { error: ResponseError }) => { ) } +const IcebergUnauthorizedError = ({ error }: { error: ResponseError }) => { + const { ref } = useParams() + + return ( + +

+ The API key from your project that's used to retrieve data from your foreign table is either + incorrect or missing. Verify the API key (token) in your{' '} + Iceberg Bucket. + Alternatively, you can also verify the token value in your{' '} + + wrapper's settings + {' '} + or in Vault. +

+ +
+ ) +} + const GeneralError = ({ error }: { error: ResponseError }) => { const { filters } = useTableFilter() @@ -183,3 +216,19 @@ const GeneralError = ({ error }: { error: ResponseError }) => { ) } + +const ExpandError = ({ error }: { error: ResponseError }) => { + return ( + + +
+ View error + +
+
+ + {error.message} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts index fe4274afc4d..cb103ee81de 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.constants.ts @@ -19,7 +19,7 @@ export const LABELS: Record = { export const DESCRIPTIONS: Record = { vault_aws_access_key_id: 'Matches the AWS access key ID from an S3 access key.', vault_aws_secret_access_key: 'Matches the AWS secret access from an S3 access key.', - vault_token: 'Corresponds to the service role key.', + vault_token: 'Corresponds to the secret or service role key.', warehouse: 'Matches the name of this bucket.', 's3.endpoint': '', catalog_uri: '', diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx new file mode 100644 index 00000000000..3876dbf344b --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx @@ -0,0 +1,136 @@ +import Link from 'next/link' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' +import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' +import { ScaffoldSection } from '@/components/layouts/Scaffold' +import { InlineLink } from '@/components/ui/InlineLink' +import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query' +import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation' +import { DOCS_URL } from '@/lib/constants' +import { isLessThan } from '@/lib/semver' + +export const ExtensionNotInstalled = ({ + bucketName, + projectRef, + wrapperMeta, + wrappersExtension, +}: { + bucketName?: string + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + const databaseNeedsUpgrading = isLessThan( + wrappersExtension?.default_version ?? '', + wrapperMeta?.minimumExtensionVersion ?? '' + ) + + return ( + <> + + +

+ The Wrappers extension is required in order to query analytics tables.{' '} + {databaseNeedsUpgrading && + 'Please first upgrade your database and then install the extension.'}{' '} + + Learn more + +

+ +
+
+ + + ) +} + +export const ExtensionNeedsUpgrade = ({ + bucketName, + projectRef, + wrapperMeta, + wrappersExtension, +}: { + bucketName?: string + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + // [Joshen] Default version is what's on the DB, so if the installed version is already the default version + // but still doesnt meet the minimum extension version, then DB upgrade is required + const databaseNeedsUpgrading = + wrappersExtension?.installed_version === wrappersExtension?.default_version + + return ( + <> + + +

+ The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} + {wrapperMeta.minimumExtensionVersion}. You have version{' '} + {wrappersExtension?.installed_version} installed. Please{' '} + {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension + by disabling and enabling the Wrappers extension. +

+

+ Before reinstalling the wrapper extension, you must first remove all existing wrappers. + Afterward, you can recreate the wrappers. +

+ +
+
+ + + ) +} + +export const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { + const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } = + useIcebergWrapperCreateMutation() + + const onSetupWrapper = async () => { + if (!bucketName) return console.error('Bucket name is required') + await createIcebergWrapper({ bucketName }) + } + + return ( + <> + + +

The Iceberg Wrapper integration is required in order to query analytics tables.

+ +
+
+ + + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx index 386def2c402..47e01441a28 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx @@ -1,8 +1,11 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Card } from 'ui' +import { Admonition } from 'ui-patterns' import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './AnalyticsBucketDetails.constants' import { CopyEnvButton } from './CopyEnvButton' import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput' +import { UpdateCatalogTokenDialog } from './UpdateCatalogTokenDialog' import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' import { INTEGRATIONS } from '@/components/interfaces/Integrations/Landing/Integrations.constants' import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' @@ -14,9 +17,16 @@ import { ScaffoldSectionTitle, } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' +import { useAPIKeys } from '@/data/api-keys/api-keys-query' +import { useVaultSecretDecryptedValueQuery } from '@/data/vault/vault-secret-decrypted-value-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string }) => { + const { data: project } = useSelectedProjectQuery() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') + const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta @@ -24,6 +34,23 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketName }) const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) + const { data: decryptedToken, isSuccess: isSuccessVaultDecrypt } = + useVaultSecretDecryptedValueQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + id: wrapperValues?.vault_token, + }, + { enabled: canReadAPIKeys } + ) + + const { data: apiKeysData, isSuccess: isSuccessApiKeys } = useAPIKeys( + { projectRef: project?.ref, reveal: true }, + { enabled: canReadAPIKeys } + ) + + const isTokenValid = apiKeysData?.allSecretKeys.some((x) => x.api_key === decryptedToken) + if (!wrapperInstance) return null return ( @@ -48,6 +75,17 @@ export const SimpleConfigurationDetails = ({ bucketName }: { bucketName?: string /> + {isSuccessApiKeys && isSuccessVaultDecrypt && !isTokenValid && ( + +

+ The Iceberg wrapper's catalog token doesn't match with any of your project's API keys, + and hence authorization will fail when connecting to your bucket. Update the catalog + token to use any of your project's API keys. +

+ {canReadAPIKeys && } +
+ )} + {wrapperMeta.server.options .filter((option) => !option.hidden && wrapperValues[option.name]) diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx new file mode 100644 index 00000000000..64e4d1d4d74 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx @@ -0,0 +1,147 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from 'ui' +import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import { InlineLink } from '@/components/ui/InlineLink' +import { useAPIKeys } from '@/data/api-keys/api-keys-query' +import { fdwKeys } from '@/data/fdw/keys' +import { vaultSecretsKeys } from '@/data/vault/keys' +import { useVaultSecretUpdateMutation } from '@/data/vault/vault-secret-update-mutation' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export const UpdateCatalogTokenDialog = ({ vaultTokenId }: { vaultTokenId?: string }) => { + const { ref } = useParams() + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') + + const [open, setOpen] = useState(false) + const [selectedKey, setSelectedKey] = useState() + + const { data: apiKeysData, isPending } = useAPIKeys( + { projectRef: ref, reveal: true }, + { enabled: canReadAPIKeys } + ) + const { allSecretKeys } = apiKeysData ?? {} + + const { mutate: updateVaultSecret, isPending: isUpdating } = useVaultSecretUpdateMutation({ + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: fdwKeys.list(ref) }), + queryClient.invalidateQueries({ + queryKey: vaultSecretsKeys.getDecryptedValue(ref, vaultTokenId), + }), + ]) + + toast.success('Successfully updated catalog token!') + setOpen(false) + }, + }) + + const onSubmit = () => { + if (!project) return console.error('Project is required') + if (!vaultTokenId) return toast.error('ID of catalog token is missing') + + updateVaultSecret({ + projectRef: project?.ref, + connectionString: project?.connectionString, + id: vaultTokenId, + secret: selectedKey, + skipClearCache: true, + }) + } + + useEffect(() => { + if (allSecretKeys?.length) setSelectedKey(allSecretKeys[0].api_key) + }, [allSecretKeys]) + + return ( + + + + + + + Update Iceberg wrapper catalog token + + + + {allSecretKeys?.length === 0 && ( + +

+ Create an API secret key from your{' '} + project's settings{' '} + first +

+
+ )} + + + {isPending ? ( + + ) : ( + + )} + + + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx index 3691ec70213..20331cd031e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx @@ -1,18 +1,17 @@ import { useParams } from 'common' import { uniq } from 'lodash' import { Loader2 } from 'lucide-react' -import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsBoolean, useQueryState } from 'nuqs' import { useEffect, useMemo, useState } from 'react' import { Button, Card, CardContent } from 'ui' import { EmptyStatePresentational } from 'ui-patterns' -import { Admonition } from 'ui-patterns/admonition' import { GenericTableLoader } from 'ui-patterns/ShimmeringLoader' import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal' import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket' import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants' +import { ExtensionNeedsUpgrade, ExtensionNotInstalled, WrapperMissing } from './BucketCallouts' import { BucketHeader } from './BucketHeader' import { CreateTableInstructions } from './CreateTable/CreateTableInstructions' import { NamespaceWithTables } from './NamespaceWithTables' @@ -31,17 +30,9 @@ import { ScaffoldSectionTitle, } from '@/components/layouts/Scaffold' import AlertError from '@/components/ui/AlertError' -import { InlineLink } from '@/components/ui/InlineLink' -import { - DatabaseExtension, - useDatabaseExtensionsQuery, -} from '@/data/database-extensions/database-extensions-query' -import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' -import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' +import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query' -import { useIcebergWrapperCreateMutation } from '@/data/storage/iceberg-wrapper-create-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL } from '@/lib/constants' export const AnalyticBucketDetails = () => { const router = useRouter() @@ -66,30 +57,14 @@ export const AnalyticBucketDetails = () => { const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0) const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0) - const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation() - const { publication, - pipeline, icebergWrapper: wrapperInstance, isLoadingWrapperInstance, } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId: bucket?.name, }) - const { data, isSuccess: isSuccessPipelineStatus } = useReplicationPipelineStatusQuery( - { projectRef, pipelineId: pipeline?.id }, - { - refetchInterval: (query) => { - const data = query.state.data - if (data?.status.name !== 'started') return 4000 - else return false - }, - } - ) - const pipelineStatus = data?.status.name - const isPipelineRunning = pipelineStatus === 'started' - const isPipelineStopped = ['failed', 'stopped'].includes(pipelineStatus ?? '') const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') @@ -224,69 +199,20 @@ export const AnalyticBucketDetails = () => { ) : null} ) : ( - <> - {!!pipeline && !!isSuccessPipelineStatus && !isPipelineRunning && ( - - - {isPipelineStopped && ( - - )} - - } - > - {!isPipelineStopped && ( - - )} - - )} -
- {namespaces.map(({ namespace, schema, tables }) => ( - - ))} -
- +
+ {namespaces.map(({ namespace, schema, tables }) => ( + + ))} +
)} @@ -329,125 +255,3 @@ export const AnalyticBucketDetails = () => { ) } - -const ExtensionNotInstalled = ({ - bucketName, - projectRef, - wrapperMeta, - wrappersExtension, -}: { - bucketName?: string - projectRef: string - wrapperMeta: WrapperMeta - wrappersExtension: DatabaseExtension -}) => { - const databaseNeedsUpgrading = - (wrappersExtension?.default_version ?? '') < (wrapperMeta?.minimumExtensionVersion ?? '') - - return ( - <> - - -

- The Wrappers extension is required in order to query analytics tables.{' '} - {databaseNeedsUpgrading && - 'Please first upgrade your database and then install the extension.'}{' '} - - Learn more - -

- -
-
- - - ) -} - -const ExtensionNeedsUpgrade = ({ - bucketName, - projectRef, - wrapperMeta, - wrappersExtension, -}: { - bucketName?: string - projectRef: string - wrapperMeta: WrapperMeta - wrappersExtension: DatabaseExtension -}) => { - // [Joshen] Default version is what's on the DB, so if the installed version is already the default version - // but still doesnt meet the minimum extension version, then DB upgrade is required - const databaseNeedsUpgrading = - wrappersExtension?.installed_version === wrappersExtension?.default_version - - return ( - <> - - -

- The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} - {wrapperMeta.minimumExtensionVersion}. You have version{' '} - {wrappersExtension?.installed_version} installed. Please{' '} - {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension - by disabling and enabling the Wrappers extension. -

-

- Before reinstalling the wrapper extension, you must first remove all existing wrappers. - Afterward, you can recreate the wrappers. -

- -
-
- - - ) -} - -const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { - const { mutateAsync: createIcebergWrapper, isPending: isCreatingIcebergWrapper } = - useIcebergWrapperCreateMutation() - - const onSetupWrapper = async () => { - if (!bucketName) return console.error('Bucket name is required') - await createIcebergWrapper({ bucketName }) - } - - return ( - <> - - -

The Iceberg Wrapper integration is required in order to query analytics tables.

- -
-
- - - ) -} diff --git a/apps/studio/data/vault/vault-secret-update-mutation.ts b/apps/studio/data/vault/vault-secret-update-mutation.ts index 942f722fdf8..f53237d998e 100644 --- a/apps/studio/data/vault/vault-secret-update-mutation.ts +++ b/apps/studio/data/vault/vault-secret-update-mutation.ts @@ -10,6 +10,7 @@ export type VaultSecretUpdateVariables = { projectRef: string connectionString?: string | null id: string + skipClearCache?: boolean } & Partial export async function updateVaultSecret({ @@ -40,9 +41,15 @@ export const useVaultSecretUpdateMutation = ({ return useMutation({ mutationFn: (vars) => updateVaultSecret(vars), async onSuccess(data, variables, context) { - const { id, projectRef } = variables + const { id, projectRef, skipClearCache = false } = variables await Promise.all([ - queryClient.removeQueries({ queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id) }), + !skipClearCache + ? queryClient.removeQueries({ + queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id), + }) + : queryClient.invalidateQueries({ + queryKey: vaultSecretsKeys.getDecryptedValue(projectRef, id), + }), queryClient.invalidateQueries({ queryKey: vaultSecretsKeys.list(projectRef) }), ]) await onSuccess?.(data, variables, context)