diff --git a/source/commands/env/copy.tsx b/source/commands/env/copy.tsx index f6c449c4..6dbe0764 100644 --- a/source/commands/env/copy.tsx +++ b/source/commands/env/copy.tsx @@ -61,6 +61,36 @@ export const options = zod.object({ "Optional: Set the environment conflict strategy. In case not set, will use 'fail'.", }), ), + dataMigration: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: Migrate data (users, roles, etc.) from source to target environment after copying.', + }), + ), + skipResources: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: When migrating data, skip resource instances, attributes, and tuples.', + }), + ), + skipUsers: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: When migrating data, skip users, role assignments, and user attributes.', + }), + ), }); type Props = { @@ -68,7 +98,17 @@ type Props = { }; export default function Copy({ - options: { apiKey, from, to, name, description, conflictStrategy }, + options: { + apiKey, + from, + to, + name, + description, + conflictStrategy, + dataMigration, + skipResources, + skipUsers, + }, }: Props) { return ( <> @@ -79,6 +119,9 @@ export default function Copy({ name={name} description={description} conflictStrategy={conflictStrategy} + dataMigration={dataMigration} + skipResources={skipResources} + skipUsers={skipUsers} /> diff --git a/source/commands/env/data-migration.tsx b/source/commands/env/data-migration.tsx new file mode 100644 index 00000000..e92e66d6 --- /dev/null +++ b/source/commands/env/data-migration.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { option } from 'pastel'; +import zod from 'zod'; +import { type infer as zInfer } from 'zod'; +import { AuthProvider } from '../../components/AuthProvider.js'; +import DataMigrationComponent from '../../components/env/DataMigrationComponent.js'; + +export const description = + 'Migrate users and data from one environment to another'; + +export const options = zod.object({ + key: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: API Key to be used for the data migration (should be at least a project level key). If not set, CLI lets you select one', + alias: 'k', + }), + ), + source: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: Environment ID to migrate data from. If not set, the CLI lets you select one.', + }), + ), + target: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: Environment ID to migrate data to. If not set, the CLI lets you select one.', + }), + ), + skipResources: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: Skip migration of resource instances, attributes, and tuples.', + }), + ), + skipUsers: zod + .boolean() + .optional() + .default(false) + .describe( + option({ + description: + 'Optional: Skip migration of users, role assignments, and user attributes.', + }), + ), + conflictStrategy: zod + .enum(['override', 'fail']) + .default('override') + .optional() + .describe( + option({ + description: + "Optional: Strategy to handle migration conflicts. Default is 'override'.", + }), + ), +}); + +type Props = { + readonly options: zInfer; +}; + +export default function DataMigration({ + options: { key, source, target, skipResources, skipUsers, conflictStrategy }, +}: Props) { + return ( + + + + ); +} diff --git a/source/components/env/CopyComponent.tsx b/source/components/env/CopyComponent.tsx index bfc2bad7..fa7520d3 100644 --- a/source/components/env/CopyComponent.tsx +++ b/source/components/env/CopyComponent.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Text } from 'ink'; +import { Text, Box } from 'ink'; import { TextInput } from '@inkjs/ui'; +import Spinner from 'ink-spinner'; import { EnvironmentCopy, useEnvironmentApi, @@ -10,6 +11,16 @@ import EnvironmentSelection, { } from '../../components/EnvironmentSelection.js'; import { cleanKey } from '../../lib/env/copy/utils.js'; import { useAuth } from '../AuthProvider.js'; +import useDataMigration from '../../hooks/useDataMigration.js'; + +// Define constants for state values to avoid duplication +const STATE_LOADING = 'loading'; +const STATE_SELECTING_ENV = 'selecting-env'; +const STATE_SELECTING_NAME = 'selecting-name'; +const STATE_SELECTING_DESCRIPTION = 'selecting-description'; +const STATE_COPYING = 'copying'; +const STATE_MIGRATING_DATA = 'migrating-data'; +const STATE_DONE = 'done'; type Props = { from?: string; @@ -17,6 +28,9 @@ type Props = { description?: string; to?: string; conflictStrategy?: 'fail' | 'overwrite'; + dataMigration?: boolean; + skipResources?: boolean; + skipUsers?: boolean; }; interface EnvCopyBody { @@ -27,23 +41,36 @@ interface EnvCopyBody { conflictStrategy?: string | null; } +// Define a specific type for component state +type CopyComponentState = + | typeof STATE_LOADING + | typeof STATE_SELECTING_ENV + | typeof STATE_SELECTING_NAME + | typeof STATE_SELECTING_DESCRIPTION + | typeof STATE_COPYING + | typeof STATE_MIGRATING_DATA + | typeof STATE_DONE; + +// Define a type for response data +interface EnvironmentResponse { + id?: string; + key?: string; + [key: string]: unknown; +} + export default function CopyComponent({ from, to: envToId, name, description, conflictStrategy, + dataMigration = false, + skipResources = false, + skipUsers = false, }: Props) { const [error, setError] = React.useState(null); const [authToken, setAuthToken] = React.useState(null); - const [state, setState] = useState< - | 'loading' - | 'selecting-env' - | 'selecting-name' - | 'selecting-description' - | 'copying' - | 'done' - >('loading'); + const [state, setState] = useState(STATE_LOADING); const [projectFrom, setProjectFrom] = useState( null, ); @@ -53,9 +80,17 @@ export default function CopyComponent({ const [envToDescription, setEnvToDescription] = useState( description, ); + const [targetEnvId, setTargetEnvId] = useState(null); + const [migrationStats, setMigrationStats] = useState<{ + users: { total: number; success: number; failed: number }; + roles: { total: number; success: number; failed: number }; + resources: { total: number; success: number; failed: number }; + roleAssignments: { total: number; success: number; failed: number }; + } | null>(null); const { copyEnvironment } = useEnvironmentApi(); const auth = useAuth(); + const { migrateAllData } = useDataMigration(); useEffect(() => { if (auth.error) { @@ -69,7 +104,7 @@ export default function CopyComponent({ }, [auth]); useEffect(() => { - if (error || state === 'done') { + if (error || state === STATE_DONE) { process.exit(1); } }, [error, state]); @@ -98,7 +133,7 @@ export default function CopyComponent({ conflict_strategy: envCopyBody.conflictStrategy ?? 'fail', }; } - const { error } = await copyEnvironment( + const { data, error } = await copyEnvironment( projectFrom ?? '', envFrom ?? '', body as EnvironmentCopy, @@ -107,7 +142,28 @@ export default function CopyComponent({ setError(`Error while copying Environment: ${error}`); return; } - setState('done'); + + // Store the target environment ID for data migration + let newEnvId = envToId; + if (!newEnvId && data) { + // Cast data to the right type + const envData = data as EnvironmentResponse; + if ('id' in envData) { + newEnvId = envData.id; + } else if (envData.key) { + newEnvId = envData.key; + } + } + + if (newEnvId) { + setTargetEnvId(newEnvId); + } + + if (dataMigration && newEnvId) { + setState(STATE_MIGRATING_DATA); + } else { + setState(STATE_DONE); + } }; if ( @@ -117,7 +173,7 @@ export default function CopyComponent({ authToken && (envToDescription !== undefined || envToId) ) { - setState('copying'); + setState(STATE_COPYING); handleEnvCopy({ newEnvKey: envToName, newEnvName: envToName, @@ -130,6 +186,7 @@ export default function CopyComponent({ authToken, conflictStrategy, copyEnvironment, + dataMigration, envFrom, envToDescription, envToId, @@ -137,6 +194,50 @@ export default function CopyComponent({ projectFrom, ]); + // Handle data migration if needed + useEffect(() => { + const performDataMigration = async () => { + if (state !== STATE_MIGRATING_DATA || !envFrom || !targetEnvId) { + return; + } + + try { + console.log( + `Starting data migration from ${envFrom} to ${targetEnvId}`, + ); + + + const migrationConflictStrategy = dataMigration + ? 'overwrite' + : conflictStrategy; + + const results = await migrateAllData(envFrom, targetEnvId, { + skipUsers, + skipResources, + conflictStrategy: migrationConflictStrategy, + }); + + setMigrationStats(results); + setState(STATE_DONE); + } catch (err) { + setError( + `Data migration failed: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + } + }; + + performDataMigration(); + }, [ + state, + envFrom, + targetEnvId, + migrateAllData, + skipUsers, + skipResources, + conflictStrategy, + dataMigration, + ]); + const handleEnvFromSelection = useCallback( ( _organisation_id: ActiveState, @@ -150,20 +251,20 @@ export default function CopyComponent({ useEffect(() => { if (!envFrom) { - setState('selecting-env'); + setState(STATE_SELECTING_ENV); } else if (!envToName && !envToId) { - setState('selecting-name'); + setState(STATE_SELECTING_NAME); } else if (envToDescription === undefined && !envToId) { - setState('selecting-description'); + setState(STATE_SELECTING_DESCRIPTION); } else if (envToName && envFrom) { // If we have name and source env, and description is defined (even if empty), proceed - setState('copying'); + setState(STATE_COPYING); } - }, [envFrom, envToDescription, envToId, envToName]); + }, [envFrom, envToDescription, envToId, envToName, dataMigration]); return ( <> - {state === 'selecting-env' && authToken && ( + {state === STATE_SELECTING_ENV && authToken && ( <> Select an existing Environment to copy from. )} - {authToken && state === 'selecting-name' && ( + {authToken && state === STATE_SELECTING_NAME && ( <> Input the new Environment name to copy to. )} - {authToken && state === 'selecting-description' && ( + {authToken && state === STATE_SELECTING_DESCRIPTION && ( <> Input the new Environment Description (press Enter to skip). @@ -193,14 +294,62 @@ export default function CopyComponent({ { setEnvToDescription(description); - setState('copying'); + setState(STATE_COPYING); }} placeholder={'Enter description here (optional)...'} /> )} - {state === 'done' && Environment copied successfully} + {state === STATE_COPYING && ( + + Copying environment... + + )} + + {state === STATE_MIGRATING_DATA && ( + + Migrating data from source to target + environment... + + )} + + {state === STATE_DONE && ( + + Environment copied successfully + + {migrationStats && ( + <> + Data Migration Summary: + + {!skipUsers && ( + <> + + Users: {migrationStats.users.success}/ + {migrationStats.users.total} + + + Roles: {migrationStats.roles.success}/ + {migrationStats.roles.total} + + + Role Assignments: {migrationStats.roleAssignments.success} + /{migrationStats.roleAssignments.total} + + + )} + {!skipResources && ( + + Resources: {migrationStats.resources.success}/ + {migrationStats.resources.total} + + )} + + + )} + + )} + {error && {error}} ); diff --git a/source/components/env/DataMigrationComponent.tsx b/source/components/env/DataMigrationComponent.tsx new file mode 100644 index 00000000..37016632 --- /dev/null +++ b/source/components/env/DataMigrationComponent.tsx @@ -0,0 +1,345 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Text, Box } from 'ink'; +import Spinner from 'ink-spinner'; +import SelectInput from 'ink-select-input'; +import { useEnvironmentApi } from '../../hooks/useEnvironmentApi.js'; +import { useProjectAPI } from '../../hooks/useProjectAPI.js'; +import { useAuth } from '../AuthProvider.js'; +import { ActiveState } from '../EnvironmentSelection.js'; +import { useDataMigration } from '../../hooks/useDataMigration.js'; + +type Props = { + source?: string; + target?: string; + skipResources?: boolean; + skipUsers?: boolean; + conflictStrategy?: 'override' | 'fail'; + onComplete?: () => void; +}; + +const MIGRATION_STATE = { + LOADING: 'loading', + SELECT_PROJECT: 'select-project', + SELECT_SOURCE: 'select-source', + SELECT_TARGET: 'select-target', + MIGRATING: 'migrating', + DONE: 'done', + ERROR: 'error', +} as const; + +// Common UI labels and messages +const WARNING_TEXT = 'Warning:'; +const UNKNOWN_ERROR = 'Unknown error'; +const SAME_ENV_ERROR = 'Source and target environments cannot be the same'; + +type MigrationState = (typeof MIGRATION_STATE)[keyof typeof MIGRATION_STATE]; + +const DataMigrationComponent: React.FC = ({ + source, + target, + conflictStrategy = 'override', + onComplete, +}) => { + const { scope } = useAuth(); + const { getEnvironments } = useEnvironmentApi(); + const { getProjects } = useProjectAPI(); + + const [sourceEnv, setSourceEnv] = useState(source || null); + const [targetEnv, setTargetEnv] = useState(target || null); + const [environments, setEnvironments] = useState([]); + const [projects, setProjects] = useState([]); + const [activeProject, setActiveProject] = useState(null); + const [state, setState] = useState(MIGRATION_STATE.LOADING); + const [error, setError] = useState(null); + const [migrationStats, setMigrationStats] = useState<{ + users: { total: number; success: number; failed: number }; + roleAssignments: { total: number; success: number; failed: number }; + } | null>(null); + + const { migrateUsers, migrateRoleAssignments } = useDataMigration(); + + // Handle completion & exit + useEffect(() => { + if (state === MIGRATION_STATE.DONE && migrationStats) { + console.log('Migration completed. Final results:'); + console.log( + `Users: ${migrationStats.users.success}/${migrationStats.users.total}`, + ); + console.log( + `Role Assignments: ${migrationStats.roleAssignments.success}/${migrationStats.roleAssignments.total}`, + ); + if (onComplete) { + onComplete(); + } else { + setTimeout(() => { + console.log('Exiting process...'); + process.exit(0); + }, 2000); + } + } + if (state === MIGRATION_STATE.ERROR) { + setTimeout(() => { + console.log('Exiting with error...'); + process.exit(1); + }, 2000); + } + }, [state, migrationStats, onComplete]); + + // Load projects + useEffect(() => { + const fetchProjects = async () => { + if (scope.project_id) { + setActiveProject(scope.project_id); + setState(MIGRATION_STATE.SELECT_SOURCE); + return; + } + try { + const { data: projectsData, error: projectsError } = + await getProjects(); + if (projectsError) { + setError(`Failed to load projects: ${projectsError}`); + setState(MIGRATION_STATE.ERROR); + return; + } + if (!projectsData || projectsData.length === 0) { + setError('No projects found'); + setState(MIGRATION_STATE.ERROR); + return; + } + if (projectsData.length === 1) { + setActiveProject(projectsData[0]!.id); + setState(MIGRATION_STATE.SELECT_SOURCE); + } else { + setProjects(projectsData.map(p => ({ label: p.name, value: p.id }))); + setState(MIGRATION_STATE.SELECT_PROJECT); + } + } catch (e) { + setError( + 'Error loading projects: ' + + (e instanceof Error ? e.message : UNKNOWN_ERROR), + ); + setState(MIGRATION_STATE.ERROR); + } + }; + fetchProjects(); + }, [getProjects, scope.project_id]); + + // Load environments + useEffect(() => { + const fetchEnvironments = async () => { + if (!activeProject) return; + try { + const { data: envData, error: envError } = + await getEnvironments(activeProject); + if (envError) { + setError(`Failed to load environments: ${envError}`); + setState(MIGRATION_STATE.ERROR); + return; + } + if (!envData || envData.length < 2) { + setError('You need at least two environments to perform migration'); + setState(MIGRATION_STATE.ERROR); + return; + } + setEnvironments( + envData.map(env => ({ label: env.name, value: env.id })), + ); + if (source) { + const found = envData.find(e => e.id === source || e.key === source); + if (found) { + setSourceEnv(found.id); + setState(MIGRATION_STATE.SELECT_TARGET); + } else if (state === MIGRATION_STATE.LOADING) { + setState(MIGRATION_STATE.SELECT_SOURCE); + } + } else if (state === MIGRATION_STATE.LOADING) { + setState(MIGRATION_STATE.SELECT_SOURCE); + } + if (target && sourceEnv) { + const found = envData.find(e => e.id === target || e.key === target); + if (found && found.id !== sourceEnv) { + setTargetEnv(found.id); + setState(MIGRATION_STATE.MIGRATING); + } + } + } catch (e) { + setError( + 'Error loading environments: ' + + (e instanceof Error ? e.message : UNKNOWN_ERROR), + ); + setState(MIGRATION_STATE.ERROR); + } + }; + if ( + activeProject && + ( + [ + MIGRATION_STATE.SELECT_SOURCE, + MIGRATION_STATE.LOADING, + ] as MigrationState[] + ).includes(state) + ) { + fetchEnvironments(); + } + }, [activeProject, getEnvironments, source, state, target, sourceEnv]); + + // Perform migration + useEffect(() => { + const performMigration = async () => { + if (state !== MIGRATION_STATE.MIGRATING || !sourceEnv || !targetEnv) + return; + try { + console.log(`DEBUG - Using project ID: ${scope.project_id}`); + console.log( + `DEBUG - Source env: ${sourceEnv}, Target env: ${targetEnv}`, + ); + let userStats = { total: 0, success: 0, failed: 0 }; + let assignmentStats = { total: 0, success: 0, failed: 0 }; + try { + console.log('Migrating users...'); + userStats = await migrateUsers( + sourceEnv, + targetEnv, + conflictStrategy, + ); + console.log('Waiting for user data to settle...'); + await new Promise(r => setTimeout(r, 1000)); + } catch (uErr) { + console.error(WARNING_TEXT, uErr); + } + try { + console.log('Migrating role assignments...'); + assignmentStats = await migrateRoleAssignments( + sourceEnv, + targetEnv, + conflictStrategy, + ); + } catch (aErr) { + console.error(WARNING_TEXT, aErr); + } + setMigrationStats({ + users: userStats, + roleAssignments: assignmentStats, + }); + setState(MIGRATION_STATE.DONE); + } catch (e) { + setError( + 'Migration failed: ' + + (e instanceof Error ? e.message : UNKNOWN_ERROR), + ); + setState(MIGRATION_STATE.ERROR); + } + }; + performMigration(); + }, [ + state, + sourceEnv, + targetEnv, + migrateUsers, + migrateRoleAssignments, + conflictStrategy, + scope.project_id, + ]); + + const handleProjectSelect = useCallback((item: { value: string }) => { + setActiveProject(item.value); + setState(MIGRATION_STATE.SELECT_SOURCE); + }, []); + + const handleSourceSelect = useCallback((item: { value: string }) => { + setSourceEnv(item.value); + setState(MIGRATION_STATE.SELECT_TARGET); + }, []); + + const handleTargetSelect = useCallback( + (item: { value: string }) => { + if (item.value === sourceEnv) { + setError(`${WARNING_TEXT} ${SAME_ENV_ERROR}`); + } else { + setTargetEnv(item.value); + setState(MIGRATION_STATE.MIGRATING); + } + }, + [sourceEnv], + ); + + return ( + + {state === MIGRATION_STATE.LOADING && ( + + Loading... + + )} + + {state === MIGRATION_STATE.SELECT_PROJECT && ( + <> + Select project: + + + )} + + {state === MIGRATION_STATE.SELECT_SOURCE && ( + <> + Select source environment: + + + )} + + {state === MIGRATION_STATE.SELECT_TARGET && sourceEnv && ( + <> + Select target environment: + e.value !== sourceEnv)} + onSelect={handleTargetSelect} + /> + + )} + + {state === MIGRATION_STATE.MIGRATING && ( + + Migrating data from{' '} + {environments.find(e => e.value === sourceEnv)?.label} to{' '} + {environments.find(e => e.value === targetEnv)?.label}... + + )} + + {state === MIGRATION_STATE.DONE && migrationStats && ( + <> + Migration completed successfully! + + Migration Summary: + + Users: {migrationStats.users.success}/{migrationStats.users.total}{' '} + migrated successfully + + + Role Assignments: {migrationStats.roleAssignments.success}/ + {migrationStats.roleAssignments.total} migrated successfully + + {migrationStats.users.failed > 0 && ( + + {WARNING_TEXT} {migrationStats.users.failed} users failed to + migrate + + )} + {migrationStats.roleAssignments.failed > 0 && ( + + {WARNING_TEXT} {migrationStats.roleAssignments.failed} role + assignments failed to migrate + + )} + + + )} + + {state === MIGRATION_STATE.ERROR && error && ( + <> + Error: {error} + Process will exit in a few seconds... + + )} + + ); +}; + +export default DataMigrationComponent; diff --git a/source/hooks/migration/types.ts b/source/hooks/migration/types.ts new file mode 100644 index 00000000..e5a2e624 --- /dev/null +++ b/source/hooks/migration/types.ts @@ -0,0 +1,49 @@ +export interface MigrationStats { + total: number; + success: number; + failed: number; + details?: string[]; +} + +export interface ResourceAction { + name: string; + description?: string; +} + +export interface ResourceAttribute { + type: string; + description?: string; +} + +export interface Resource { + key: string; + name?: string; + description?: string; + actions?: Record; + attributes?: Record; +} + +export interface User { + key: string; + email?: string; + first_name?: string; + last_name?: string; + attributes?: Record; +} + +export interface Role { + key: string; + name: string; + description?: string; + permissions?: string[]; + resource?: string; +} + +export interface RoleAssignment { + user: string | { key: string; [key: string]: unknown }; + role: string | { key: string; [key: string]: unknown }; + tenant?: string | { key: string; [key: string]: unknown }; + resource_instance?: string; +} + +export type ConflictStrategy = 'override' | 'fail'; diff --git a/source/hooks/migration/useMigrateResources.ts b/source/hooks/migration/useMigrateResources.ts new file mode 100644 index 00000000..9968dc10 --- /dev/null +++ b/source/hooks/migration/useMigrateResources.ts @@ -0,0 +1,230 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import { components } from '../../lib/api/v1.js'; + +type ResourceRead = components['schemas']['ResourceRead']; +type ResourceCreate = components['schemas']['ResourceCreate']; +type ResourceUpdatePayload = components['schemas']['ResourceUpdate']; +type ActionBlockEditable = components['schemas']['ActionBlockEditable']; +type AttributeBlockEditable = components['schemas']['AttributeBlockEditable']; +type RelationBlockRead = components['schemas']['RelationBlockRead']; + +const useMigrateResources = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + const migrateResources = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const cleanResourceDataForUpdate = ( + resource: ResourceRead, + ): ResourceUpdatePayload => { + const cleaned: ResourceUpdatePayload = { + name: resource.name || resource.key || '', + description: resource.description || '', + actions: {}, + attributes: {}, + relations: {}, + urn: resource.urn || undefined, + }; + + if (resource.actions) { + for (const actionKey in resource.actions) { + const action = resource.actions[actionKey]; + if (action) { + cleaned.actions![actionKey] = { + name: action.name || actionKey, + description: action.description || '', + attributes: action.attributes, + }; + } + } + } + + if (resource.attributes) { + for (const attrKey in resource.attributes) { + const attr = resource.attributes[attrKey]; + if (attr) { + cleaned.attributes![attrKey] = { + type: attr.type, + description: attr.description || '', + }; + } + } + } + + if (resource.relations) { + for (const relationKey in resource.relations) { + const relationInfo = resource.relations[relationKey]; + if (relationInfo && relationInfo.resource) { + cleaned.relations![relationKey] = relationInfo.resource; + } + } + } + return cleaned; + }; + + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + + try { + if (!scope.project_id) throw new Error('Project ID missing'); + + const { data: sourceResResp, error: sourceErr } = + await authenticatedApiClient().GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + { env_id: sourceEnvId }, + ); + + if (sourceErr) { + stats.details?.push(`Get source error: ${sourceErr}`); + return stats; + } + if (!sourceResResp) { + stats.details?.push('No source resources'); + return stats; + } + + const sourceResources: ReadonlyArray = Array.isArray( + sourceResResp, + ) + ? sourceResResp + : sourceResResp.data || []; + + const { data: targetResResp } = await authenticatedApiClient().GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + { env_id: targetEnvId }, + ); + const targetResources: ReadonlyArray = targetResResp + ? Array.isArray(targetResResp) + ? targetResResp + : targetResResp.data || [] + : []; + const targetResourceKeys = new Set(targetResources.map(r => r.key)); + + stats.total = sourceResources.length; + + for (const resource of sourceResources) { + try { + if (!resource?.key) { + stats.failed++; + continue; + } + + const resourceDataForPatch = cleanResourceDataForUpdate(resource); + + // Construct payload matching ResourceCreate schema for POST call + const resourceDataForPost: ResourceCreate = { + key: resource.key, + name: resource.name || resource.key, + description: resource.description || undefined, + urn: resource.urn || undefined, + actions: Object.entries(resource.actions || {}).reduce( + (acc, [key, action]) => { + acc[key] = { + name: action.name || key, + description: action.description || '', + attributes: action.attributes, + }; + return acc; + }, + {} as { [key: string]: ActionBlockEditable }, + ), + + attributes: Object.entries(resource.attributes || {}).reduce( + (acc, [key, attr]) => { + acc[key] = { + type: attr.type, + description: attr.description || '', + }; + return acc; + }, + {} as { [key: string]: AttributeBlockEditable }, + ), + + roles: resource.roles as + | { [key: string]: components['schemas']['RoleBlockEditable'] } + | undefined, + // Map relations: Transform from RelationBlockRead to {[key: string]: string} + relations: Object.entries(resource.relations || {}).reduce( + (acc, [key, relationInfo]) => { + const relInfo = relationInfo as RelationBlockRead; + if (relInfo && relInfo.resource) { + acc[key] = relInfo.resource; + } + return acc; + }, + {} as { [key: string]: string }, + ), + }; + + if (targetResourceKeys.has(resource.key)) { + if (conflictStrategy === 'override') { + try { + const updateResult = await authenticatedApiClient().PATCH( + `/v2/schema/{proj_id}/{env_id}/resources/{resource_id}`, + { resource_id: resource.key }, + resourceDataForPatch, + undefined, + ); + if (updateResult.error) { + stats.failed++; + stats.details?.push(`Update error: ${updateResult.error}`); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push(`Update exception: ${error}`); + } + } else { + stats.failed++; + stats.details?.push(`Conflict: ${resource.key}`); + } + } else { + try { + const createResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/resources`, + { env_id: targetEnvId }, + resourceDataForPost, + undefined, + ); + if (createResult.error) { + stats.failed++; + stats.details?.push(`Create error: ${createResult.error}`); + } else { + stats.success++; + targetResourceKeys.add(resource.key); + } + } catch (error) { + stats.failed++; + stats.details?.push(`Create exception: ${error}`); + } + } + } catch (resourceError) { + stats.failed++; + stats.details?.push(`Processing error: ${resourceError}`); + } + } + return stats; + } catch (err) { + stats.details?.push(`Migration error: ${err}`); + return stats; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + return { migrateResources }; +}; + +export default useMigrateResources; diff --git a/source/hooks/migration/useMigrateRoleAssignments.ts b/source/hooks/migration/useMigrateRoleAssignments.ts new file mode 100644 index 00000000..788abf21 --- /dev/null +++ b/source/hooks/migration/useMigrateRoleAssignments.ts @@ -0,0 +1,210 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import useMigrateRoles from './useMigrateRoles.js'; + +const ERROR_ROLE_DOES_NOT_EXIST = 'role does not exist'; +const ERROR_FAILED_TO_ASSIGN = 'Failed to assign role '; +const ERROR_ROLE_ASSIGNMENT = 'Role assignment migration error: '; +const ERROR_CREATING_ASSIGNMENT = 'Error creating assignment: '; +const ERROR_PROCESSING_ASSIGNMENT = 'Error processing assignment: '; +const ERROR_NO_ROLE_ASSIGNMENTS = + 'No role assignments found in source environment'; +const ERROR_NO_PROJECT_ID = 'Project ID is not available in the current scope'; +const ERROR_GETTING_ASSIGNMENTS = 'Error getting role assignments: '; +const UNKNOWN_ERROR = 'Unknown error'; + +const useMigrateRoleAssignments = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + const { getRoles } = useMigrateRoles(); + + const migrateRoleAssignments = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + + try { + if (!scope.project_id) { + throw new Error(ERROR_NO_PROJECT_ID); + } + + // Get role assignments + const { data: roleAssignmentsResponse, error: roleAssignmentsError } = + await authenticatedApiClient().GET( + `/v2/facts/{proj_id}/{env_id}/role_assignments`, + { + env_id: sourceEnvId, + }, + undefined, + { per_page: 100 }, + ); + + if (roleAssignmentsError) { + stats.details?.push( + `${ERROR_GETTING_ASSIGNMENTS}${roleAssignmentsError}`, + ); + return stats; + } + + if (!roleAssignmentsResponse) { + stats.details?.push(ERROR_NO_ROLE_ASSIGNMENTS); + return stats; + } + + // Handle different response formats + const assignments = Array.isArray(roleAssignmentsResponse) + ? roleAssignmentsResponse + : roleAssignmentsResponse && + typeof roleAssignmentsResponse === 'object' && + 'data' in roleAssignmentsResponse && + Array.isArray(roleAssignmentsResponse.data) + ? roleAssignmentsResponse.data + : []; + + // Filter for top-level assignments only + const topLevelAssignments = assignments.filter( + assignment => assignment && !assignment.resource_instance, + ); + + stats.total = topLevelAssignments.length; + + // Get the list of valid roles in the target environment + const { roles: targetRoles } = await getRoles(targetEnvId); + + // Create a set of valid role keys for tracking + const validRoleKeys = new Set( + targetRoles?.map(role => role?.key).filter(Boolean), + ); + + // Process each role assignment + for (let i = 0; i < topLevelAssignments.length; i++) { + const assignment = topLevelAssignments[i]; + + try { + if (!assignment || !assignment.user || !assignment.role) { + stats.failed++; + continue; + } + + // Extract string values for role and user + const roleKey = + typeof assignment.role === 'object' + ? assignment.role.key + : String(assignment.role); + + const userKey = + typeof assignment.user === 'object' + ? assignment.user.key + : String(assignment.user); + + const tenantKey = + assignment.tenant && typeof assignment.tenant === 'object' + ? assignment.tenant.key + : String(assignment.tenant || 'default'); + + // Check if role exists in target environment + if (!validRoleKeys.has(roleKey)) { + // Try to create the role on-the-fly as a fallback + try { + const createRoleResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/roles`, + { + env_id: targetEnvId, + }, + { + key: roleKey, + name: roleKey, + description: `Auto-created role during migration`, + }, + ); + + if (!createRoleResult.error) { + validRoleKeys.add(roleKey); + } + } catch { + // Continue with assignment attempt even if role creation fails + } + } + + // Create assignment object with proper typing + const assignmentData = { + user: userKey, + role: roleKey, + tenant: tenantKey, + }; + + try { + const createResult = await authenticatedApiClient().POST( + `/v2/facts/{proj_id}/{env_id}/role_assignments`, + { + env_id: targetEnvId, + }, + assignmentData, + ); + + if (createResult.error) { + // Handle specific error cases + if ( + createResult.error.includes && + (createResult.error.includes("could not find 'Role'") || + createResult.error.includes(ERROR_ROLE_DOES_NOT_EXIST)) + ) { + stats.failed++; + stats.details?.push( + `${ERROR_FAILED_TO_ASSIGN}${roleKey}: ${ERROR_ROLE_DOES_NOT_EXIST}`, + ); + } else if ( + createResult.error.includes && + createResult.error.includes('already exists') && + conflictStrategy === 'override' + ) { + // Consider as success if it already exists and override is enabled + stats.success++; + } else { + stats.failed++; + stats.details?.push( + `${ERROR_FAILED_TO_ASSIGN}${roleKey}: ${createResult.error}`, + ); + } + } else { + stats.success++; + } + } catch (createError) { + stats.failed++; + stats.details?.push( + `${ERROR_CREATING_ASSIGNMENT}${createError instanceof Error ? createError.message : UNKNOWN_ERROR}`, + ); + } + } catch (assignmentError) { + stats.failed++; + stats.details?.push( + `${ERROR_PROCESSING_ASSIGNMENT}${assignmentError instanceof Error ? assignmentError.message : UNKNOWN_ERROR}`, + ); + } + } + + return stats; + } catch (err) { + stats.details?.push( + `${ERROR_ROLE_ASSIGNMENT}${err instanceof Error ? err.message : UNKNOWN_ERROR}`, + ); + return stats; + } + }, + [authenticatedApiClient, getRoles, scope.project_id], + ); + + return { migrateRoleAssignments }; +}; + +export default useMigrateRoleAssignments; diff --git a/source/hooks/migration/useMigrateRoles.ts b/source/hooks/migration/useMigrateRoles.ts new file mode 100644 index 00000000..5c331364 --- /dev/null +++ b/source/hooks/migration/useMigrateRoles.ts @@ -0,0 +1,186 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import { components } from '../../lib/api/v1.js'; + +type RoleRead = components['schemas']['RoleRead']; +type RoleCreate = components['schemas']['RoleCreate']; +type RoleUpdatePayload = components['schemas']['RoleUpdate']; + +const useMigrateRoles = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + const getRoles = useCallback( + async ( + environmentId: string, + ): Promise<{ roles: RoleRead[]; error: string | null }> => { + if (!scope.project_id) return { roles: [], error: 'Project ID missing' }; + const rolesEndpoint = `/v2/schema/{proj_id}/{env_id}/roles`; + try { + const { data: rolesResponse, error } = + await authenticatedApiClient().GET(rolesEndpoint, { + env_id: environmentId, + }); + if (error) return { roles: [], error }; + if (rolesResponse) { + const roles = Array.isArray(rolesResponse) + ? rolesResponse + : rolesResponse.data || []; + if (roles.length > 0) { + return { roles: roles as RoleRead[], error: null }; + } + } + return { roles: [], error: 'No roles found' }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unknown error getting roles'; + return { roles: [], error: message }; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + const migrateRoles = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const cleanRoleDataForUpdate = (role: RoleRead): RoleUpdatePayload => { + // Uses the RoleRead type which has optional extends/attributes + return { + name: role.name || role.key, + description: role.description || undefined, + permissions: role.permissions || undefined, + extends: role.extends || undefined, + attributes: role.attributes || undefined, + }; + }; + + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + try { + if (!scope.project_id) throw new Error('Project ID missing'); + + const { roles: sourceRoles, error: sourceRolesError } = + await getRoles(sourceEnvId); + if (sourceRolesError) { + stats.details?.push(`Get source roles error: ${sourceRolesError}`); + return stats; + } + if (!sourceRoles || sourceRoles.length === 0) { + stats.details?.push('No source roles found'); + return stats; + } + + const { roles: targetRoles } = await getRoles(targetEnvId); + const targetRoleKeys = new Set( + targetRoles?.map(role => role.key).filter(Boolean), + ); + stats.total = sourceRoles.length; + + for (const role of sourceRoles) { + try { + if (!role?.key) { + stats.failed++; + continue; + } + + const roleDataForPost: RoleCreate = { + key: role.key, + name: role.name || role.key, + description: role.description || undefined, + permissions: role.permissions || undefined, + extends: role.extends || undefined, + attributes: role.attributes || undefined, + + granted_to: role.granted_to as + | components['schemas']['DerivedRoleBlockEdit'] + | undefined, + }; + const roleDataForPatch = cleanRoleDataForUpdate(role); + + if (targetRoleKeys.has(role.key)) { + if (conflictStrategy === 'override') { + try { + const updateEndpoint = `/v2/schema/{proj_id}/{env_id}/roles/{role_id}`; + const updateResult = await authenticatedApiClient().PATCH( + updateEndpoint, + { role_id: role.key }, + roleDataForPatch, + undefined, + ); + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Update role error ${role.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Update role exception ${role.key}: ${error}`, + ); + } + } else { + stats.failed++; + stats.details?.push(`Conflict role: ${role.key}`); + } + } else { + try { + const createResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/roles`, + { env_id: targetEnvId }, + roleDataForPost, + undefined, + ); + if (createResult.error) { + stats.failed++; + const errorMessage = + typeof createResult.error === 'string' + ? createResult.error + : JSON.stringify(createResult.error); + stats.details?.push( + `Create role error ${role.key}: ${errorMessage}`, + ); + } else { + stats.success++; + targetRoleKeys.add(role.key); + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Create role exception ${role.key}: ${error}`, + ); + } + } + } catch (roleError) { + stats.failed++; + stats.details?.push( + `Processing role error ${role.key}: ${roleError}`, + ); + } + } + return stats; + } catch (err) { + stats.details?.push(`Role migration error: ${err}`); + return stats; + } + }, + [getRoles, authenticatedApiClient, scope.project_id], + ); + + return { migrateRoles, getRoles }; +}; + +export default useMigrateRoles; diff --git a/source/hooks/migration/useMigrateUsers.ts b/source/hooks/migration/useMigrateUsers.ts new file mode 100644 index 00000000..f79221a4 --- /dev/null +++ b/source/hooks/migration/useMigrateUsers.ts @@ -0,0 +1,163 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, ConflictStrategy } from './types.js'; +import { components } from '../../lib/api/v1.js'; + +// Define a type for the API response +interface ApiResponse { + data?: T; + error: string | null; + response?: Response; + status?: number; +} + +const useMigrateUsers = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + const migrateUsers = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + conflictStrategy: ConflictStrategy = 'override', + ): Promise => { + const stats: MigrationStats = { + total: 0, + success: 0, + failed: 0, + details: [], + }; + try { + if (!scope.project_id) throw new Error('Project ID missing'); + + const { data: sourceUsersResponse } = + await authenticatedApiClient().GET( + `/v2/facts/{proj_id}/{env_id}/users`, + { env_id: sourceEnvId }, + undefined, + { per_page: 100 }, + ); + + if (!sourceUsersResponse) { + stats.details?.push('No source users'); + return stats; + } + + const users: ReadonlyArray = + Array.isArray(sourceUsersResponse) + ? sourceUsersResponse + : sourceUsersResponse.data || []; + + stats.total = users.length; + + // Get the API client + const client = authenticatedApiClient(); + + const post = client.POST as ( + path: string, + pathParams: Record, + body: unknown, + queryParams?: Record, + ) => Promise>; + + const put = client.PUT as ( + path: string, + pathParams: Record, + body: unknown, + queryParams?: Record, + ) => Promise>; + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + try { + if (!user || !user.key) { + stats.failed++; + continue; + } + + // Construct payload matching UserCreate schema + const userData: components['schemas']['UserCreate'] = { + key: user.key, + email: user.email || undefined, + first_name: user.first_name || undefined, + last_name: user.last_name || undefined, + attributes: user.attributes || {}, + }; + + try { + const createResult = await post( + `/v2/facts/{proj_id}/{env_id}/users`, + { env_id: targetEnvId }, + userData, + undefined, + ); + + if (createResult.error) { + const errorMessage = + typeof createResult.error === 'string' + ? createResult.error + : JSON.stringify(createResult.error); + + if ( + errorMessage.includes('already exists') && + conflictStrategy === 'override' + ) { + try { + const updateResult = await put( + `/v2/facts/{proj_id}/{env_id}/users/{user_id}`, + { env_id: targetEnvId, user_id: user.key }, + userData, + undefined, + ); + + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Update user error ${user.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Update user exception ${user.key}: ${error}`, + ); + } + } else { + stats.failed++; + stats.details?.push( + `Create user error ${user.key}: ${errorMessage}`, + ); + } + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Create/Update user exception ${user.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } catch (userError) { + const userKey = user?.key || `unknown_user_at_index_${i}`; + stats.failed++; + stats.details?.push( + `Processing user error ${userKey}: ${userError instanceof Error ? userError.message : 'Unknown error'}`, + ); + } + } + return stats; + } catch (err) { + stats.details?.push(`User migration error: ${err}`); + return stats; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + return { migrateUsers }; +}; + +export default useMigrateUsers; diff --git a/source/hooks/useDataMigration.tsx b/source/hooks/useDataMigration.tsx new file mode 100644 index 00000000..0aec6970 --- /dev/null +++ b/source/hooks/useDataMigration.tsx @@ -0,0 +1,127 @@ +import { useCallback } from 'react'; +import useMigrateUsers from './migration/useMigrateUsers.js'; +import useMigrateRoles from './migration/useMigrateRoles.js'; +import useMigrateResources from './migration/useMigrateResources.js'; +import useMigrateRoleAssignments from './migration/useMigrateRoleAssignments.js'; +import { ConflictStrategy } from './migration/types.js'; + +export const useDataMigration = () => { + const { migrateUsers } = useMigrateUsers(); + const { migrateRoles } = useMigrateRoles(); + const { migrateResources } = useMigrateResources(); + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + + const migrateAllData = useCallback( + async ( + sourceEnvId: string, + targetEnvId: string, + options: { + skipUsers?: boolean; + skipResources?: boolean; + conflictStrategy?: ConflictStrategy; + } = {}, + ) => { + const { + skipUsers = false, + skipResources = false, + conflictStrategy = 'override', + } = options; + + const results = { + users: { total: 0, success: 0, failed: 0 }, + roles: { total: 0, success: 0, failed: 0 }, + resources: { total: 0, success: 0, failed: 0 }, + roleAssignments: { total: 0, success: 0, failed: 0 }, + }; + + try { + // First, migrate resources if not skipped (resources should come first) + if (!skipResources) { + console.log('Migrating resources...'); + const resourceStats = await migrateResources( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.resources = { + total: resourceStats.total, + success: resourceStats.success, + failed: resourceStats.failed, + }; + console.log( + `Resources migrated: ${resourceStats.success}/${resourceStats.total}`, + ); + } + + // Then, migrate users if not skipped + if (!skipUsers) { + console.log('Migrating users...'); + const userStats = await migrateUsers( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.users = { + total: userStats.total, + success: userStats.success, + failed: userStats.failed, + }; + console.log( + `Users migrated: ${userStats.success}/${userStats.total}`, + ); + + // Migrate roles (needed for role assignments) + console.log('Migrating roles...'); + const roleStats = await migrateRoles( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.roles = { + total: roleStats.total, + success: roleStats.success, + failed: roleStats.failed, + }; + console.log( + `Roles migrated: ${roleStats.success}/${roleStats.total}`, + ); + + // Add a small delay to ensure users and roles are processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Finally, migrate role assignments + console.log('Migrating role assignments...'); + const roleAssignmentStats = await migrateRoleAssignments( + sourceEnvId, + targetEnvId, + conflictStrategy, + ); + results.roleAssignments = { + total: roleAssignmentStats.total, + success: roleAssignmentStats.success, + failed: roleAssignmentStats.failed, + }; + console.log( + `Role assignments migrated: ${roleAssignmentStats.success}/${roleAssignmentStats.total}`, + ); + } + + return results; + } catch (error) { + console.error('Migration error:', error); + throw error; + } + }, + [migrateUsers, migrateRoles, migrateResources, migrateRoleAssignments], + ); + + return { + migrateAllData, + migrateUsers, + migrateRoles, + migrateResources, + migrateRoleAssignments, + }; +}; + +export default useDataMigration;