From f4e62c4202ce65dcb650a9baed4ef2c792123a0e Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 14 Oct 2025 00:24:29 +0200 Subject: [PATCH] WIP: Migrating from bespoke 's3Configs' that where assotiated to a workingDir to standard s3Profile --- web/src/core/adapters/s3Client/index.ts | 56 +++ web/src/core/adapters/s3Client/s3Client.ts | 46 --- .../core/ports/OnyxiaApi/DeploymentRegion.ts | 58 ++- .../decoupledLogic/ProjectConfigs.ts | 2 - .../usecases/s3ConfigConnectionTest/state.ts | 22 +- .../usecases/s3ConfigConnectionTest/thunks.ts | 17 +- .../decoupledLogic/getS3Configs.ts | 340 ------------------ .../getWorkingDirectoryBucket.ts | 33 -- .../decoupledLogic/getWorkingDirectoryPath.ts | 51 --- .../decoupledLogic/projectS3ConfigId.ts | 23 -- .../decoupledLogic/resolveS3AdminBookmarks.ts | 187 ---------- .../resolveTemplatedBookmark.ts | 120 +++++++ .../decoupledLogic/s3Profile_fromVault_id.ts | 25 ++ .../decoupledLogic/s3Profiles.ts | 185 ++++++++++ ...eDefaultS3ConfigsAfterPotentialDeletion.ts | 71 ---- ...DefaultS3ProfilesAfterPotentialDeletion.ts | 65 ++++ .../core/usecases/s3ConfigManagement/index.ts | 2 +- .../usecases/s3ConfigManagement/selectors.ts | 131 ++----- .../core/usecases/s3ConfigManagement/state.ts | 13 +- .../usecases/s3ConfigManagement/thunks.ts | 86 +++-- 20 files changed, 577 insertions(+), 956 deletions(-) delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts create mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveTemplatedBookmark.ts create mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profile_fromVault_id.ts create mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profiles.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts create mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts diff --git a/web/src/core/adapters/s3Client/index.ts b/web/src/core/adapters/s3Client/index.ts index 30fc813a0..d7a6f3147 100644 --- a/web/src/core/adapters/s3Client/index.ts +++ b/web/src/core/adapters/s3Client/index.ts @@ -1 +1,57 @@ export * from "./s3Client"; + +const x = { + workingDirectory: { + bucketMode: "multi", + bucketNamePrefix: "", + bucketNamePrefixGroup: "projet-" + }, + bookmarkedDirectories: [ + { + fullPath: "donnees-insee/diffusion/", + title: { + fr: "Données de diffusion", + en: "Dissemination Data" + }, + description: { + fr: "Bucket public destiné à la diffusion de données", + en: "Public bucket intended for data dissemination" + } + } + ] +}; +const s3 = { + bookmarkedDirectories: [ + { + fullPath: "$1/", + title: "Personal", + description: "Personal storage", + claimName: "preferred_username" + }, + { + fullPath: "projet-$1/", + title: "Group $1", + description: "Shared storage for project $1", + claimName: "groups", + excludedClaimPattern: "^USER_ONYXIA$" + }, + { + fullPath: "donnees-insee/diffusion/", + title: { + fr: "Données de diffusion", + en: "Dissemination Data" + }, + description: { + fr: "Bucket public destiné à la diffusion de données", + en: "Public bucket intended for data dissemination" + } + } + ] +}; + +/* + + + + +*/ diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 4b47741a2..493ad0949 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -52,7 +52,6 @@ export namespace ParamsOfCreateS3Client { roleSessionName: string; } | undefined; - nameOfBucketToCreateIfNotExist: string | undefined; }; } @@ -251,51 +250,6 @@ export function createS3Client( return { getAwsS3Client }; })(); - create_bucket: { - if (!params.isStsEnabled) { - break create_bucket; - } - - const { nameOfBucketToCreateIfNotExist } = params; - - if (nameOfBucketToCreateIfNotExist === undefined) { - break create_bucket; - } - - const { awsS3Client } = await getAwsS3Client(); - - const { CreateBucketCommand, BucketAlreadyExists, BucketAlreadyOwnedByYou } = - await import("@aws-sdk/client-s3"); - - try { - await awsS3Client.send( - new CreateBucketCommand({ - Bucket: nameOfBucketToCreateIfNotExist - }) - ); - } catch (error) { - assert(is(error)); - - if ( - !(error instanceof BucketAlreadyExists) && - !(error instanceof BucketAlreadyOwnedByYou) - ) { - console.log( - "An unexpected error occurred while creating the bucket, we ignore it:", - error - ); - break create_bucket; - } - - console.log( - [ - `The above network error is expected we tried creating the `, - `bucket ${nameOfBucketToCreateIfNotExist} in case it didn't exist but it did.` - ].join(" ") - ); - } - } - return { getNewlyRequestedOrCachedToken, clearCachedToken, getAwsS3Client }; })(); diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 8d8453a3b..3439a8714 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -21,9 +21,7 @@ export type DeploymentRegion = { initScriptUrl: string; s3Configs: DeploymentRegion.S3Config[]; s3ConfigCreationFormDefaults: - | (Pick & { - workingDirectory: DeploymentRegion.S3Config["workingDirectory"] | undefined; - }) + | Pick | undefined; allowedURIPatternForUserDefinedInitScript: string; kafka: @@ -121,43 +119,27 @@ export namespace DeploymentRegion { | undefined; oidcParams: OidcParams_Partial; }; - workingDirectory: - | { - bucketMode: "shared"; - bucketName: string; - prefix: string; - prefixGroup: string; - } - | { - bucketMode: "multi"; - bucketNamePrefix: string; - bucketNamePrefixGroup: string; - }; - bookmarkedDirectories: S3Config.BookmarkedDirectory[]; + bookmarks: S3Config.Bookmark[]; }; export namespace S3Config { - export type BookmarkedDirectory = - | BookmarkedDirectory.Static - | BookmarkedDirectory.Dynamic; - - export namespace BookmarkedDirectory { - export type Common = { - fullPath: string; - title: LocalizedString; - description: LocalizedString | undefined; - tags: LocalizedString[] | undefined; - }; - - export type Static = Common & { - claimName: undefined; - }; - - export type Dynamic = Common & { - claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; - }; - } + export type Bookmark = { + bucket: string; + keyPrefix: string; + title: LocalizedString; + description: LocalizedString; + tags: LocalizedString[]; + } & ( + | { + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; + } + | { + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); } } diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts index 7e90cc574..7934e1b81 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts @@ -24,7 +24,6 @@ export namespace ProjectConfigs { friendlyName: string; url: string; region: string | undefined; - workingDirectoryPath: string; pathStyleAccess: boolean; credentials: | { @@ -108,7 +107,6 @@ const zS3Config = (() => { friendlyName: z.string(), url: z.string(), region: z.union([z.string(), z.undefined()]), - workingDirectoryPath: z.string(), pathStyleAccess: z.boolean(), credentials: z.union([zS3Credentials, z.undefined()]) }); diff --git a/web/src/core/usecases/s3ConfigConnectionTest/state.ts b/web/src/core/usecases/s3ConfigConnectionTest/state.ts index 3fb6eecd7..473306204 100644 --- a/web/src/core/usecases/s3ConfigConnectionTest/state.ts +++ b/web/src/core/usecases/s3ConfigConnectionTest/state.ts @@ -10,12 +10,10 @@ type State = { export type OngoingConfigTest = { paramsOfCreateS3Client: ParamsOfCreateS3Client; - workingDirectoryPath: string; }; export type ConfigTestResult = { paramsOfCreateS3Client: ParamsOfCreateS3Client; - workingDirectoryPath: string; result: | { isSuccess: true; @@ -43,20 +41,17 @@ export const { actions, reducer } = createUsecaseActions({ payload: State["ongoingConfigTests"][number]; } ) => { - const { paramsOfCreateS3Client, workingDirectoryPath } = payload; + const { paramsOfCreateS3Client } = payload; if ( state.ongoingConfigTests.find(e => - same(e, { paramsOfCreateS3Client, workingDirectoryPath }) + same(e, { paramsOfCreateS3Client }) ) !== undefined ) { return; } - state.ongoingConfigTests.push({ - paramsOfCreateS3Client, - workingDirectoryPath - }); + state.ongoingConfigTests.push({ paramsOfCreateS3Client }); }, testCompleted: ( state, @@ -66,11 +61,11 @@ export const { actions, reducer } = createUsecaseActions({ payload: State["configTestResults"][number]; } ) => { - const { paramsOfCreateS3Client, workingDirectoryPath, result } = payload; + const { paramsOfCreateS3Client, result } = payload; remove_from_ongoing: { const entry = state.ongoingConfigTests.find(e => - same(e, { paramsOfCreateS3Client, workingDirectoryPath }) + same(e, { paramsOfCreateS3Client }) ); if (entry === undefined) { @@ -84,10 +79,8 @@ export const { actions, reducer } = createUsecaseActions({ } remove_existing_result: { - const entry = state.configTestResults.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath + const entry = state.configTestResults.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) ); if (entry === undefined) { @@ -99,7 +92,6 @@ export const { actions, reducer } = createUsecaseActions({ state.configTestResults.push({ paramsOfCreateS3Client, - workingDirectoryPath, result }); } diff --git a/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts b/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts index c7deed032..cb8f94f2e 100644 --- a/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts +++ b/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts @@ -8,18 +8,13 @@ export const thunks = {} satisfies Thunks; export const protectedThunks = { testS3Connection: - (params: { - paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; - workingDirectoryPath: string; - }) => + (params: { paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts }) => async (...args) => { - const { paramsOfCreateS3Client, workingDirectoryPath } = params; + const { paramsOfCreateS3Client } = params; const [dispatch] = args; - dispatch( - actions.testStarted({ paramsOfCreateS3Client, workingDirectoryPath }) - ); + dispatch(actions.testStarted({ paramsOfCreateS3Client })); const result = await (async () => { const { createS3Client } = await import("core/adapters/s3Client"); @@ -31,9 +26,8 @@ export const protectedThunks = { const s3Client = createS3Client(paramsOfCreateS3Client, getOidc); try { - await s3Client.listObjects({ - path: workingDirectoryPath - }); + console.log("Find a way to test only s3 credential", s3Client); + throw new Error("TODO: Not implemented yet"); } catch (error) { return { isSuccess: false as const, @@ -47,7 +41,6 @@ export const protectedThunks = { dispatch( actions.testCompleted({ paramsOfCreateS3Client, - workingDirectoryPath, result }) ); diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts deleted file mode 100644 index e737e9b3b..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts +++ /dev/null @@ -1,340 +0,0 @@ -import * as projectManagement from "core/usecases/projectManagement"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; -import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import { same } from "evt/tools/inDepth/same"; -import { getWorkingDirectoryPath } from "./getWorkingDirectoryPath"; -import { getWorkingDirectoryBucketToCreate } from "./getWorkingDirectoryBucket"; -import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; -import { assert, type Equals } from "tsafe/assert"; -import { getProjectS3ConfigId } from "./projectS3ConfigId"; -import type * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; -import type { LocalizedString } from "core/ports/OnyxiaApi"; -import type { ResolvedAdminBookmark } from "./resolveS3AdminBookmarks"; - -export type S3Config = S3Config.FromDeploymentRegion | S3Config.FromProject; - -export namespace S3Config { - type Common = { - id: string; - dataSource: string; - region: string | undefined; - workingDirectoryPath: string; - isXOnyxiaDefault: boolean; - isExplorerConfig: boolean; - }; - - export type FromDeploymentRegion = Common & { - origin: "deploymentRegion"; - paramsOfCreateS3Client: ParamsOfCreateS3Client; - locations: FromDeploymentRegion.Location[]; - }; - - export namespace FromDeploymentRegion { - export type Location = - | Location.Personal - | Location.Project - | Location.AdminBookmark; - - export namespace Location { - type Common = { directoryPath: string }; - - export type Personal = Common & { - type: "personal"; - }; - - export type Project = Common & { - type: "project"; - projectName: string; - }; - export type AdminBookmark = Common & { - type: "bookmark"; - title: LocalizedString; - description?: LocalizedString; - tags: LocalizedString[] | undefined; - }; - } - } - - export type FromProject = Common & { - origin: "project"; - paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; - creationTime: number; - friendlyName: string; - connectionTestStatus: - | { status: "not tested" } - | { status: "test ongoing" } - | { status: "test failed"; errorMessage: string } - | { status: "test succeeded" }; - }; -} - -export function getS3Configs(params: { - projectConfigsS3: projectManagement.ProjectConfigs["s3"]; - s3RegionConfigs: DeploymentRegion.S3Config[]; - resolvedAdminBookmarks: ResolvedAdminBookmark[]; - configTestResults: s3ConfigConnectionTest.ConfigTestResult[]; - ongoingConfigTests: s3ConfigConnectionTest.OngoingConfigTest[]; - username: string; - projectGroup: string | undefined; - groupProjects: { - name: string; - group: string; - }[]; -}): S3Config[] { - const { - projectConfigsS3: { - s3Configs: s3ProjectConfigs, - s3ConfigId_defaultXOnyxia, - s3ConfigId_explorer - }, - s3RegionConfigs, - resolvedAdminBookmarks, - configTestResults, - ongoingConfigTests, - username, - projectGroup, - groupProjects - } = params; - - const getDataSource = (params: { - url: string; - pathStyleAccess: boolean; - workingDirectoryPath: string; - }): string => { - const { url, pathStyleAccess, workingDirectoryPath } = params; - - let out = url; - - out = out.replace(/^https?:\/\//, "").replace(/\/$/, ""); - - const { bucketName, objectName } = - bucketNameAndObjectNameFromS3Path(workingDirectoryPath); - - out = pathStyleAccess - ? `${out}/${bucketName}/${objectName}` - : `${bucketName}.${out}/${objectName}`; - - return out; - }; - - const getConnectionTestStatus = (params: { - workingDirectoryPath: string; - paramsOfCreateS3Client: ParamsOfCreateS3Client; - }): S3Config.FromProject["connectionTestStatus"] => { - const { workingDirectoryPath, paramsOfCreateS3Client } = params; - - if ( - ongoingConfigTests.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ) !== undefined - ) { - return { status: "test ongoing" }; - } - - has_result: { - const { result } = - configTestResults.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ) ?? {}; - - if (result === undefined) { - break has_result; - } - - return result.isSuccess - ? { status: "test succeeded" } - : { status: "test failed", errorMessage: result.errorMessage }; - } - - return { status: "not tested" }; - }; - - const s3Configs: S3Config[] = [ - ...s3ProjectConfigs - .map((c): S3Config.FromProject => { - const id = getProjectS3ConfigId({ - creationTime: c.creationTime - }); - - const workingDirectoryPath = c.workingDirectoryPath; - const url = c.url; - const pathStyleAccess = c.pathStyleAccess; - const region = c.region; - - const paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts = { - url, - pathStyleAccess, - isStsEnabled: false, - region, - credentials: c.credentials - }; - - return { - origin: "project", - creationTime: c.creationTime, - friendlyName: c.friendlyName, - id, - dataSource: getDataSource({ - url, - pathStyleAccess, - workingDirectoryPath - }), - region, - workingDirectoryPath, - paramsOfCreateS3Client, - isXOnyxiaDefault: false, - isExplorerConfig: false, - connectionTestStatus: getConnectionTestStatus({ - paramsOfCreateS3Client, - workingDirectoryPath - }) - }; - }) - .sort((a, b) => b.creationTime - a.creationTime), - ...s3RegionConfigs.map((c, i): S3Config.FromDeploymentRegion => { - const id = `region-${fnv1aHashToHex( - JSON.stringify( - Object.fromEntries( - Object.entries(c).sort(([key1], [key2]) => - key1.localeCompare(key2) - ) - ) - ) - )}`; - - const workingDirectoryContext = - projectGroup === undefined - ? { - type: "personalProject" as const, - username - } - : { - type: "groupProject" as const, - projectGroup - }; - - const workingDirectoryPath = getWorkingDirectoryPath({ - workingDirectory: c.workingDirectory, - context: workingDirectoryContext - }); - - const personalWorkingDirectoryPath = getWorkingDirectoryPath({ - workingDirectory: c.workingDirectory, - context: { - type: "personalProject" as const, - username - } - }); - - const url = c.url; - const pathStyleAccess = c.pathStyleAccess; - const region = c.region; - - const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { - url, - pathStyleAccess, - isStsEnabled: true, - stsUrl: c.sts.url, - region, - oidcParams: c.sts.oidcParams, - durationSeconds: c.sts.durationSeconds, - role: c.sts.role, - nameOfBucketToCreateIfNotExist: getWorkingDirectoryBucketToCreate({ - workingDirectory: c.workingDirectory, - context: workingDirectoryContext - }) - }; - - const adminBookmarks: S3Config.FromDeploymentRegion.Location.AdminBookmark[] = - (() => { - const entry = resolvedAdminBookmarks.find( - ({ s3ConfigIndex }) => s3ConfigIndex === i - ); - - if (entry === undefined) { - return []; - } - - return entry.bookmarkedDirectories.map( - ({ title, description, fullPath, tags }) => ({ - title, - description, - type: "bookmark", - directoryPath: fullPath, - tags - }) - ); - })(); - - const projectsLocations: S3Config.FromDeploymentRegion.Location.Project[] = - groupProjects.map(({ group }) => { - const directoryPath = getWorkingDirectoryPath({ - workingDirectory: c.workingDirectory, - context: { - type: "groupProject", - projectGroup: group - } - }); - return { type: "project", directoryPath, projectName: group }; - }); - - const dataSource = getDataSource({ - url, - pathStyleAccess, - workingDirectoryPath - }); - - return { - origin: "deploymentRegion", - id, - dataSource, - region, - workingDirectoryPath, - locations: [ - { type: "personal", directoryPath: personalWorkingDirectoryPath }, - ...projectsLocations, - ...adminBookmarks - ], - paramsOfCreateS3Client, - isXOnyxiaDefault: false, - isExplorerConfig: false - }; - }) - ]; - - ( - [ - ["defaultXOnyxia", s3ConfigId_defaultXOnyxia], - ["explorer", s3ConfigId_explorer] - ] as const - ).forEach(([prop, s3ConfigId]) => { - if (s3ConfigId === undefined) { - return; - } - - const s3Config = - s3Configs.find(({ id }) => id === s3ConfigId) ?? - s3Configs.find(s3Config => s3Config.origin === "deploymentRegion"); - - if (s3Config === undefined) { - return; - } - - switch (prop) { - case "defaultXOnyxia": - s3Config.isXOnyxiaDefault = true; - return; - case "explorer": - s3Config.isExplorerConfig = true; - return; - } - assert>(false); - }); - - return s3Configs; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts deleted file mode 100644 index b776713dc..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { assert, type Equals } from "tsafe/assert"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi"; - -export function getWorkingDirectoryBucketToCreate(params: { - workingDirectory: DeploymentRegion.S3Config["workingDirectory"]; - context: - | { - type: "personalProject"; - username: string; - } - | { - type: "groupProject"; - projectGroup: string; - }; -}): string | undefined { - const { workingDirectory, context } = params; - - switch (workingDirectory.bucketMode) { - case "shared": - return undefined; - case "multi": - return (() => { - switch (context.type) { - case "personalProject": - return `${workingDirectory.bucketNamePrefix}${context.username}`; - case "groupProject": - return `${workingDirectory.bucketNamePrefixGroup}${context.projectGroup}`; - } - assert>(false); - })(); - } - assert>(false); -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts deleted file mode 100644 index 8c310703c..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { assert, type Equals } from "tsafe/assert"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi"; -import { getWorkingDirectoryBucketToCreate } from "./getWorkingDirectoryBucket"; - -export function getWorkingDirectoryPath(params: { - workingDirectory: DeploymentRegion.S3Config["workingDirectory"]; - context: - | { - type: "personalProject"; - username: string; - } - | { - type: "groupProject"; - projectGroup: string; - }; -}): string { - const { workingDirectory, context } = params; - - return ( - (() => { - switch (workingDirectory.bucketMode) { - case "multi": { - const bucketName = getWorkingDirectoryBucketToCreate({ - workingDirectory, - context - }); - assert(bucketName !== undefined); - return bucketName; - } - case "shared": - return [ - workingDirectory.bucketName, - (() => { - switch (context.type) { - case "personalProject": - return `${workingDirectory.prefix}${context.username}`; - case "groupProject": - return `${workingDirectory.prefixGroup}${context.projectGroup}`; - } - assert>(true); - })() - ].join("/"); - } - assert>(false); - })() - .trim() - .replace(/\/\//g, "/") // Remove double slashes if any - .replace(/^\//g, "") // Ensure no leading slash - .replace(/\/+$/g, "") + "/" // Enforce trailing slash - ); -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts deleted file mode 100644 index 4ed797345..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { assert } from "tsafe/assert"; - -const prefix = "project-"; - -export function getProjectS3ConfigId(params: { creationTime: number }): string { - const { creationTime } = params; - - return `${prefix}${creationTime}`; -} - -export function parseProjectS3ConfigId(params: { s3ConfigId: string }): { - creationTime: number; -} { - const { s3ConfigId } = params; - - const creationTimeStr = s3ConfigId.replace(prefix, ""); - - const creationTime = parseInt(creationTimeStr); - - assert(!isNaN(creationTime), "Not a valid s3 project config id"); - - return { creationTime }; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts deleted file mode 100644 index 9e57702a4..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { DeploymentRegion, OidcParams_Partial } from "core/ports/OnyxiaApi"; -import { assert } from "tsafe/assert"; -import { id } from "tsafe/id"; -import type { LocalizedString } from "ui/i18n"; -import memoizee from "memoizee"; - -export type DeploymentRegion_S3ConfigLike = { - sts: { - oidcParams: OidcParams_Partial; - }; - bookmarkedDirectories: DeploymentRegion.S3Config.BookmarkedDirectory[]; -}; - -assert; - -export type ResolvedAdminBookmark = { - s3ConfigIndex: number; - bookmarkedDirectories: DeploymentRegion.S3Config.BookmarkedDirectory.Common[]; -}; - -export async function resolveS3AdminBookmarks(params: { - deploymentRegion_s3Configs: DeploymentRegion_S3ConfigLike[]; - getDecodedIdToken: (params: { - oidcParams_partial: OidcParams_Partial; - }) => Promise>; -}): Promise<{ - resolvedAdminBookmarks: ResolvedAdminBookmark[]; -}> { - const { deploymentRegion_s3Configs, getDecodedIdToken } = params; - - const resolvedAdminBookmarks = await Promise.all( - deploymentRegion_s3Configs.map(async (s3Config, i) => { - const getDecodedIdToken_memo = memoizee( - () => - getDecodedIdToken({ - oidcParams_partial: s3Config.sts.oidcParams - }), - { promise: true } - ); - - return id({ - s3ConfigIndex: i, - bookmarkedDirectories: ( - await Promise.all( - s3Config.bookmarkedDirectories.map(async entry => { - if (entry.claimName === undefined) { - return [ - id( - { - fullPath: entry.fullPath, - description: entry.description, - tags: entry.tags, - title: entry.title - } - ) - ]; - } - - const { - claimName, - excludedClaimPattern, - includedClaimPattern - } = entry; - - const decodedIdToken = await getDecodedIdToken_memo(); - - const claimValue_arr: string[] = (() => { - const value = decodedIdToken[claimName]; - - if (!value) return []; - - if (typeof value === "string") return [value]; - if (Array.isArray(value)) return value.map(e => `${e}`); - - assert( - false, - () => - `${claimName} not in expected format! ${JSON.stringify(decodedIdToken)}` - ); - })(); - - const includedRegex = includedClaimPattern - ? new RegExp(includedClaimPattern) - : undefined; - const excludedRegex = excludedClaimPattern - ? new RegExp(excludedClaimPattern) - : undefined; - - return claimValue_arr - .map(value => { - if ( - excludedRegex !== undefined && - excludedRegex.test(value) - ) - return []; - - if (includedRegex === undefined) { - return []; - } - - const match = includedRegex.exec(value); - - if (!match) { - return []; - } - - return [ - id( - { - fullPath: substituteTemplateString({ - template: entry.fullPath, - match - }), - title: substituteLocalizedString({ - localizedString: entry.title, - match - }) as LocalizedString, - description: substituteLocalizedString({ - localizedString: entry.description, - match - }), - tags: substituteLocalizedStringArray({ - array: entry.tags, - match - }) - } - ) - ]; - }) - .flat(); - }) - ) - ).flat() - }); - }) - ); - - return { resolvedAdminBookmarks }; -} - -function substituteTemplateString(params: { - template: string; - match: RegExpExecArray; -}): string { - const { template, match } = params; - return template.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); -} - -const substituteLocalizedStringArray = (params: { - array: LocalizedString[] | undefined; - match: RegExpExecArray; -}): LocalizedString[] | undefined => { - const { array, match } = params; - - if (array === undefined) return undefined; - - return array.map(str => - substituteLocalizedString({ - localizedString: str, - match - }) - ); -}; - -function substituteLocalizedString(params: { - localizedString: T; - match: RegExpExecArray; -}): T { - const { localizedString: input, match } = params; - - if (input === undefined) return undefined as T; - - if (typeof input === "string") { - return substituteTemplateString({ template: input, match }) as T; - } - - const result = Object.fromEntries( - Object.entries(input).map(([lang, value]) => [ - lang, - typeof value === "string" - ? substituteTemplateString({ template: value, match }) - : value - ]) - ); - - return result as T; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveTemplatedBookmark.ts new file mode 100644 index 000000000..bb277d2b1 --- /dev/null +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -0,0 +1,120 @@ +import type { DeploymentRegion } from "core/ports/OnyxiaApi"; +import { id } from "tsafe/id"; +import type { LocalizedString } from "ui/i18n"; +import type { S3Profile } from "./s3Profiles"; +import { z } from "zod"; +import { getValueAtPath } from "core/tools/Stringifyable"; + +export async function resolveTemplatedBookmark(params: { + bookmark_region: DeploymentRegion.S3Config.Bookmark; + getDecodedIdToken: () => Promise>; +}): Promise { + const { bookmark_region, getDecodedIdToken } = params; + + if (bookmark_region.claimName === undefined) { + return [ + id({ + title: bookmark_region.title, + description: bookmark_region.description, + tags: bookmark_region.tags, + bucket: bookmark_region.bucket, + keyPrefix: bookmark_region.keyPrefix + }) + ]; + } + + const { claimName, excludedClaimPattern, includedClaimPattern } = bookmark_region; + + const decodedIdToken = await getDecodedIdToken(); + + const claimValue_arr: string[] = (() => { + let claimValue_untrusted: unknown = (() => { + const candidate = decodedIdToken[claimName]; + + if (candidate !== undefined) { + return candidate; + } + + const claimPath = claimName.split("."); + + if (claimPath.length === 1) { + return undefined; + } + + return getValueAtPath({ + // @ts-expect-error: We know decodedIdToken is Stringifyable + stringifyableObjectOrArray: decodedIdToken, + doDeleteFromSource: false, + doFailOnUnresolved: false, + path: claimPath + }); + })(); + + if (!claimValue_untrusted) { + return []; + } + + let claimValue: string | string[]; + + try { + claimValue = z + .union([z.string(), z.array(z.string())]) + .parse(claimValue_untrusted); + } catch (error) { + throw new Error( + [ + `decodedIdToken -> ${claimName} is supposed to be`, + `string or array of string`, + `The decoded id token is:`, + JSON.stringify(decodedIdToken, null, 2) + ].join(" "), + { cause: error } + ); + } + + return claimValue instanceof Array ? claimValue : [claimValue]; + })(); + + const includedRegex = + includedClaimPattern !== undefined ? new RegExp(includedClaimPattern) : /^(.+)$/; + const excludedRegex = + excludedClaimPattern !== undefined ? new RegExp(excludedClaimPattern) : undefined; + + return claimValue_arr + .map(value => { + if (excludedRegex !== undefined && excludedRegex.test(value)) { + return undefined; + } + + const match = includedRegex.exec(value); + + if (match === null) { + return undefined; + } + + const substituteTemplateString = (str: string) => + str.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); + + const substituteLocalizedString = ( + locStr: LocalizedString + ): LocalizedString => { + if (typeof locStr === "string") { + return substituteTemplateString(locStr); + } + return Object.fromEntries( + Object.entries(locStr) + .filter(([, value]) => value !== undefined) + .map(([lang, value]) => [lang, substituteTemplateString(value)]) + ); + }; + + return id({ + bucket: substituteTemplateString(bookmark_region.bucket), + keyPrefix: substituteTemplateString(bookmark_region.keyPrefix), + title: substituteLocalizedString(bookmark_region.title), + description: substituteLocalizedString(bookmark_region.description), + tags: bookmark_region.tags.map(tag => substituteLocalizedString(tag)) + }); + }) + .filter(x => x !== undefined); +} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profile_fromVault_id.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profile_fromVault_id.ts new file mode 100644 index 000000000..aac399899 --- /dev/null +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profile_fromVault_id.ts @@ -0,0 +1,25 @@ +import { assert } from "tsafe"; + +// TODO: Eventually rename but we keep it as project- +// for avoiding painful breaking change requiring a migration. +const prefix = "project-"; + +export function s3Profile_fromVault_getId(params: { creationTime: number }): string { + const { creationTime } = params; + + return `${prefix}${creationTime}`; +} + +export function s3Profile_fromVault_parseId(params: { s3ConfigId: string }): { + creationTime: number; +} { + const { s3ConfigId } = params; + + const creationTimeStr = s3ConfigId.replace(prefix, ""); + + const creationTime = parseInt(creationTimeStr); + + assert(!isNaN(creationTime), "Not a valid s3 vault config id"); + + return { creationTime }; +} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profiles.ts new file mode 100644 index 000000000..7c87a6cab --- /dev/null +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/s3Profiles.ts @@ -0,0 +1,185 @@ +import * as projectManagement from "core/usecases/projectManagement"; +import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; +import { same } from "evt/tools/inDepth/same"; +import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; +import { assert, type Equals, id } from "tsafe"; +import { s3Profile_fromVault_getId } from "./s3Profile_fromVault_id"; +import type * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; + +export type S3Profile = S3Profile.FromRegion | S3Profile.FromVault; + +export namespace S3Profile { + type Common = { + id: string; + isXOnyxiaDefault: boolean; + isExplorerConfig: boolean; + }; + + export type FromRegion = Common & { + origin: "region"; + paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; + bookmarks: Bookmark[]; + }; + + export type FromVault = Common & { + origin: "vault"; + paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; + creationTime: number; + friendlyName: string; + connectionTestStatus: + | { status: "not tested" } + | { status: "test ongoing" } + | { status: "test failed"; errorMessage: string } + | { status: "test succeeded" }; + }; + + export type Bookmark = { + title: LocalizedString; + description?: LocalizedString; + tags: LocalizedString[] | undefined; + bucket: string; + keyPrefix: string; + }; +} + +export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { + fromVault: projectManagement.ProjectConfigs["s3"]; + fromRegion: { + s3Config: DeploymentRegion.S3Config; + bookmarks: S3Profile.Bookmark[]; + }[]; + connectionTestsState: { + results: s3ConfigConnectionTest.ConfigTestResult[]; + ongoing: s3ConfigConnectionTest.OngoingConfigTest[]; + }; +}): S3Profile[] { + const { fromVault, fromRegion, connectionTestsState } = params; + + const getConnectionTestStatus = (params: { + paramsOfCreateS3Client: ParamsOfCreateS3Client; + }): S3Profile.FromVault["connectionTestStatus"] => { + const { paramsOfCreateS3Client } = params; + + if ( + connectionTestsState.ongoing.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ) !== undefined + ) { + return { status: "test ongoing" }; + } + + has_result: { + const { result } = + connectionTestsState.results.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ) ?? {}; + + if (result === undefined) { + break has_result; + } + + return result.isSuccess + ? { status: "test succeeded" } + : { status: "test failed", errorMessage: result.errorMessage }; + } + + return { status: "not tested" }; + }; + + const s3Profiles: S3Profile[] = [ + ...fromVault.s3Configs + .map((c): S3Profile.FromVault => { + const url = c.url; + const pathStyleAccess = c.pathStyleAccess; + const region = c.region; + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts = { + url, + pathStyleAccess, + isStsEnabled: false, + region, + credentials: c.credentials + }; + + return { + origin: "vault", + creationTime: c.creationTime, + friendlyName: c.friendlyName, + id: s3Profile_fromVault_getId({ + creationTime: c.creationTime + }), + paramsOfCreateS3Client, + isXOnyxiaDefault: false, + isExplorerConfig: false, + connectionTestStatus: getConnectionTestStatus({ + paramsOfCreateS3Client + }) + }; + }) + .sort((a, b) => b.creationTime - a.creationTime), + ...fromRegion.map(({ s3Config: c, bookmarks }): S3Profile.FromRegion => { + const url = c.url; + const pathStyleAccess = c.pathStyleAccess; + const region = c.region; + + return { + origin: "region", + id: `region-${fnv1aHashToHex( + JSON.stringify( + Object.fromEntries( + Object.entries(c).sort(([key1], [key2]) => + key1.localeCompare(key2) + ) + ) + ) + )}`, + bookmarks, + paramsOfCreateS3Client: id({ + url, + pathStyleAccess, + isStsEnabled: true, + stsUrl: c.sts.url, + region, + oidcParams: c.sts.oidcParams, + durationSeconds: c.sts.durationSeconds, + role: c.sts.role + }), + isXOnyxiaDefault: false, + isExplorerConfig: false + }; + }) + ]; + + ( + [ + ["defaultXOnyxia", fromVault.s3ConfigId_defaultXOnyxia], + ["explorer", fromVault.s3ConfigId_explorer] + ] as const + ).forEach(([prop, s3ProfileId]) => { + if (s3ProfileId === undefined) { + return; + } + + const s3Profile = + s3Profiles.find(({ id }) => id === s3ProfileId) ?? + s3Profiles.find(s3Config => s3Config.origin === "region"); + + if (s3Profile === undefined) { + return; + } + + switch (prop) { + case "defaultXOnyxia": + s3Profile.isXOnyxiaDefault = true; + return; + case "explorer": + s3Profile.isExplorerConfig = true; + return; + } + assert>(false); + }); + + return s3Profiles; +} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts deleted file mode 100644 index 7113da304..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as projectManagement from "core/usecases/projectManagement"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -import { getS3Configs } from "./getS3Configs"; - -type R = Record< - "s3ConfigId_defaultXOnyxia" | "s3ConfigId_explorer", - | { - isUpdateNeeded: false; - } - | { - isUpdateNeeded: true; - s3ConfigId: string | undefined; - } ->; - -export function updateDefaultS3ConfigsAfterPotentialDeletion(params: { - projectConfigsS3: { - s3Configs: projectManagement.ProjectConfigs.S3Config[]; - s3ConfigId_defaultXOnyxia: string | undefined; - s3ConfigId_explorer: string | undefined; - }; - s3RegionConfigs: DeploymentRegion.S3Config[]; -}): R { - const { projectConfigsS3, s3RegionConfigs } = params; - - const s3Configs = getS3Configs({ - projectConfigsS3, - s3RegionConfigs, - configTestResults: [], - resolvedAdminBookmarks: [], - ongoingConfigTests: [], - username: "johndoe", - projectGroup: undefined, - groupProjects: [] - }); - - const actions: R = { - s3ConfigId_defaultXOnyxia: { - isUpdateNeeded: false - }, - s3ConfigId_explorer: { - isUpdateNeeded: false - } - }; - - for (const propertyName of [ - "s3ConfigId_defaultXOnyxia", - "s3ConfigId_explorer" - ] as const) { - const s3ConfigId_default = projectConfigsS3[propertyName]; - - if (s3ConfigId_default === undefined) { - continue; - } - - if (s3Configs.find(({ id }) => id === s3ConfigId_default) !== undefined) { - continue; - } - - const s3ConfigId_toUseAsDefault = s3Configs.find( - ({ origin }) => origin === "deploymentRegion" - )?.id; - - actions[propertyName] = { - isUpdateNeeded: true, - s3ConfigId: s3ConfigId_toUseAsDefault - }; - } - - return actions; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts new file mode 100644 index 000000000..1612355cf --- /dev/null +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -0,0 +1,65 @@ +import * as projectManagement from "core/usecases/projectManagement"; +import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; +import { aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet } from "./s3Profiles"; + +type R = Record< + "s3ConfigId_defaultXOnyxia" | "s3ConfigId_explorer", + | { + isUpdateNeeded: false; + } + | { + isUpdateNeeded: true; + s3ProfileId: string | undefined; + } +>; + +export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { + fromRegion: DeploymentRegion.S3Config[]; + fromVault: projectManagement.ProjectConfigs["s3"]; +}): R { + const { fromRegion, fromVault } = params; + + const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ + fromRegion: fromRegion.map(s3Config => ({ s3Config, bookmarks: [] })), + fromVault, + connectionTestsState: { + results: [], + ongoing: [] + } + }); + + const actions: R = { + s3ConfigId_defaultXOnyxia: { + isUpdateNeeded: false + }, + s3ConfigId_explorer: { + isUpdateNeeded: false + } + }; + + for (const propertyName of [ + "s3ConfigId_defaultXOnyxia", + "s3ConfigId_explorer" + ] as const) { + const s3ConfigId_default = fromVault[propertyName]; + + if (s3ConfigId_default === undefined) { + continue; + } + + if (s3Profiles.find(({ id }) => id === s3ConfigId_default) !== undefined) { + continue; + } + + const s3ConfigId_toUseAsDefault = s3Profiles.find( + ({ origin }) => origin === "region" + )?.id; + + actions[propertyName] = { + isUpdateNeeded: true, + s3ProfileId: s3ConfigId_toUseAsDefault + }; + } + + return actions; +} diff --git a/web/src/core/usecases/s3ConfigManagement/index.ts b/web/src/core/usecases/s3ConfigManagement/index.ts index 479cc3f02..84fe07fe2 100644 --- a/web/src/core/usecases/s3ConfigManagement/index.ts +++ b/web/src/core/usecases/s3ConfigManagement/index.ts @@ -1,4 +1,4 @@ export * from "./state"; export * from "./selectors"; export * from "./thunks"; -export type { S3Config } from "./decoupledLogic/getS3Configs"; +export type { S3Profile } from "./decoupledLogic/s3Profiles"; diff --git a/web/src/core/usecases/s3ConfigManagement/selectors.ts b/web/src/core/usecases/s3ConfigManagement/selectors.ts index 4586886a7..cd8768a78 100644 --- a/web/src/core/usecases/s3ConfigManagement/selectors.ts +++ b/web/src/core/usecases/s3ConfigManagement/selectors.ts @@ -1,21 +1,21 @@ import { createSelector } from "clean-architecture"; -import type { LocalizedString } from "core/ports/OnyxiaApi"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; import * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; -import * as userAuthentication from "core/usecases/userAuthentication"; import { assert } from "tsafe/assert"; -import { exclude } from "tsafe/exclude"; -import { getS3Configs, type S3Config } from "./decoupledLogic/getS3Configs"; +import { + type S3Profile, + aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet +} from "./decoupledLogic/s3Profiles"; import { name } from "./state"; import type { State as RootState } from "core/bootstrap"; -const resolvedAdminBookmarks = createSelector( +const resolvedTemplatedBookmarks = createSelector( (state: RootState) => state[name], - state => state.resolvedAdminBookmarks + state => state.resolvedTemplatedBookmarks ); -const s3Configs = createSelector( +const s3Profiles = createSelector( createSelector( projectManagement.protectedSelectors.projectConfig, projectConfig => projectConfig.s3 @@ -24,104 +24,39 @@ const s3Configs = createSelector( deploymentRegionManagement.selectors.currentDeploymentRegion, deploymentRegion => deploymentRegion.s3Configs ), - resolvedAdminBookmarks, + resolvedTemplatedBookmarks, s3ConfigConnectionTest.protectedSelectors.configTestResults, s3ConfigConnectionTest.protectedSelectors.ongoingConfigTests, - createSelector(userAuthentication.selectors.main, ({ isUserLoggedIn, user }) => { - assert(isUserLoggedIn); - return user.username; - }), - createSelector( - projectManagement.protectedSelectors.currentProject, - project => project.group - ), - createSelector(projectManagement.protectedSelectors.projects, projects => { - return projects - .map(({ name, group }) => (group === undefined ? undefined : { name, group })) - .filter(exclude(undefined)); - }), ( - projectConfigsS3, - s3RegionConfigs, - resolvedAdminBookmarks, + projectConfigS3, + s3Configs_region, + resolvedTemplatedBookmarks, configTestResults, - ongoingConfigTests, - username, - projectGroup, - groupProjects - ): S3Config[] => - getS3Configs({ - projectConfigsS3, - s3RegionConfigs, - resolvedAdminBookmarks, - configTestResults, - ongoingConfigTests, - username, - projectGroup, - groupProjects + ongoingConfigTests + ): S3Profile[] => + aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ + fromVault: projectConfigS3, + fromRegion: s3Configs_region.map((s3Config, i) => ({ + s3Config, + bookmarks: (() => { + const entry = resolvedTemplatedBookmarks.find( + entry => entry.correspondingS3ConfigIndexInRegion === i + ); + + assert(entry !== undefined); + + return entry.bookmarks; + })() + })), + connectionTestsState: { + results: configTestResults, + ongoing: ongoingConfigTests + } }) ); -type IndexedS3Locations = - | IndexedS3Locations.AdminCreatedS3Config - | IndexedS3Locations.UserCreatedS3Config; - -namespace IndexedS3Locations { - export namespace AdminCreatedS3Config { - type Common = { directoryPath: string }; - - export type PersonalLocation = Common & { - type: "personal"; - }; - - export type ProjectLocation = Common & { - type: "project"; - projectName: string; - }; - export type AdminBookmarkLocation = Common & { - type: "bookmark"; - title: LocalizedString; - description?: LocalizedString; - tags: LocalizedString[] | undefined; - }; - - export type Location = PersonalLocation | ProjectLocation | AdminBookmarkLocation; - } - - export type AdminCreatedS3Config = { - type: "admin created s3 config"; - locations: AdminCreatedS3Config.Location[]; - }; - - export type UserCreatedS3Config = { - type: "user created s3 config"; - directoryPath: string; - dataSource: string; - }; -} - -const indexedS3Locations = createSelector(s3Configs, (s3Configs): IndexedS3Locations => { - const s3Config = s3Configs.find(({ isExplorerConfig }) => isExplorerConfig); - - assert(s3Config !== undefined); - - switch (s3Config.origin) { - case "deploymentRegion": - return { - type: "admin created s3 config", - locations: s3Config.locations - }; - case "project": - return { - type: "user created s3 config", - directoryPath: s3Config.workingDirectoryPath, - dataSource: s3Config.dataSource - }; - } -}); - -export const selectors = { s3Configs, indexedS3Locations }; +export const selectors = { s3Profiles }; export const privateSelectors = { - resolvedAdminBookmarks + resolvedTemplatedBookmarks }; diff --git a/web/src/core/usecases/s3ConfigManagement/state.ts b/web/src/core/usecases/s3ConfigManagement/state.ts index e97d2c9d1..ac253a0cf 100644 --- a/web/src/core/usecases/s3ConfigManagement/state.ts +++ b/web/src/core/usecases/s3ConfigManagement/state.ts @@ -1,11 +1,14 @@ -import type { ResolvedAdminBookmark } from "./decoupledLogic/resolveS3AdminBookmarks"; import { createUsecaseActions, createObjectThatThrowsIfAccessed } from "clean-architecture"; +import type { S3Profile } from "./decoupledLogic/s3Profiles"; type State = { - resolvedAdminBookmarks: ResolvedAdminBookmark[]; + resolvedTemplatedBookmarks: { + correspondingS3ConfigIndexInRegion: number; + bookmarks: S3Profile.Bookmark[]; + }[]; }; export const name = "s3ConfigManagement"; @@ -20,14 +23,14 @@ export const { reducer, actions } = createUsecaseActions({ payload }: { payload: { - resolvedAdminBookmarks: ResolvedAdminBookmark[]; + resolvedTemplatedBookmarks: State["resolvedTemplatedBookmarks"]; }; } ) => { - const { resolvedAdminBookmarks } = payload; + const { resolvedTemplatedBookmarks } = payload; const state: State = { - resolvedAdminBookmarks + resolvedTemplatedBookmarks }; return state; diff --git a/web/src/core/usecases/s3ConfigManagement/thunks.ts b/web/src/core/usecases/s3ConfigManagement/thunks.ts index 88cf38457..e122769eb 100644 --- a/web/src/core/usecases/s3ConfigManagement/thunks.ts +++ b/web/src/core/usecases/s3ConfigManagement/thunks.ts @@ -12,7 +12,7 @@ import { updateDefaultS3ConfigsAfterPotentialDeletion } from "./decoupledLogic/u import structuredClone from "@ungap/structured-clone"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; -import { resolveS3AdminBookmarks } from "./decoupledLogic/resolveS3AdminBookmarks"; +import { resolveTemplatedBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; import { actions } from "./state"; export const thunks = { @@ -33,8 +33,7 @@ export const thunks = { await dispatch( s3ConfigConnectionTest.protectedThunks.testS3Connection({ - paramsOfCreateS3Client: s3Config.paramsOfCreateS3Client, - workingDirectoryPath: s3Config.workingDirectoryPath + paramsOfCreateS3Client: s3Config.paramsOfCreateS3Client }) ); }, @@ -313,41 +312,60 @@ export const protectedThunks = { async (...args) => { const [dispatch, getState, { onyxiaApi, paramsOfBootstrapCore }] = args; - const { oidcParams } = await onyxiaApi.getAvailableRegionsAndOidcParams(); - - if (oidcParams === undefined) { - dispatch(actions.initialized({ resolvedAdminBookmarks: [] })); - return; - } const deploymentRegion = deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); - const { resolvedAdminBookmarks } = await resolveS3AdminBookmarks({ - deploymentRegion_s3Configs: deploymentRegion.s3Configs, - getDecodedIdToken: async ({ oidcParams_partial }) => { - const { createOidc, mergeOidcParams } = await import( - "core/adapters/oidc" - ); - - const oidc = await createOidc({ - ...mergeOidcParams({ - oidcParams, - oidcParams_partial - }), - autoLogin: true, - transformBeforeRedirectForKeycloakTheme: - paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, - getCurrentLang: paramsOfBootstrapCore.getCurrentLang, - enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs - }); - - const { decodedIdToken } = await oidc.getTokens(); - - return decodedIdToken; - } - }); + const resolvedTemplatedBookmarks = await Promise.all( + deploymentRegion.s3Configs.map(async (s3Config, s3ConfigIndex) => { + const { + bookmarks, + sts: { oidcParams: oidcParams_partial } + } = s3Config; + + const getDecodedIdToken = async () => { + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + + const { oidcParams } = + await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const oidc = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial + }), + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const { decodedIdToken } = await oidc.getTokens(); + + return decodedIdToken; + }; + + return { + correspondingS3ConfigIndexInRegion: s3ConfigIndex, + bookmarks: ( + await Promise.all( + bookmarks.map(bookmark => + resolveTemplatedBookmark({ + bookmark_region: bookmark, + getDecodedIdToken + }) + ) + ) + ).flat() + }; + }) + ); - dispatch(actions.initialized({ resolvedAdminBookmarks })); + dispatch(actions.initialized({ resolvedTemplatedBookmarks })); } } satisfies Thunks;