From 57d01c4a8da34061ef05cbd4f3c0638ee6678719 Mon Sep 17 00:00:00 2001 From: daveads Date: Wed, 30 Apr 2025 07:09:28 +0100 Subject: [PATCH 1/3] data migration --- source/commands/env/data-migration.tsx | 90 +++++ .../components/env/DataMigrationComponent.tsx | 380 ++++++++++++++++++ source/hooks/migration/types.ts | 49 +++ source/hooks/migration/useMigrateResources.ts | 236 +++++++++++ .../migration/useMigrateRoleAssignments.ts | 203 ++++++++++ source/hooks/migration/useMigrateRoles.ts | 220 ++++++++++ source/hooks/migration/useMigrateUsers.ts | 151 +++++++ source/hooks/useDataMigration.tsx | 21 + 8 files changed, 1350 insertions(+) create mode 100644 source/commands/env/data-migration.tsx create mode 100644 source/components/env/DataMigrationComponent.tsx create mode 100644 source/hooks/migration/types.ts create mode 100644 source/hooks/migration/useMigrateResources.ts create mode 100644 source/hooks/migration/useMigrateRoleAssignments.ts create mode 100644 source/hooks/migration/useMigrateRoles.ts create mode 100644 source/hooks/migration/useMigrateUsers.ts create mode 100644 source/hooks/useDataMigration.tsx 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/DataMigrationComponent.tsx b/source/components/env/DataMigrationComponent.tsx new file mode 100644 index 00000000..84c8ef13 --- /dev/null +++ b/source/components/env/DataMigrationComponent.tsx @@ -0,0 +1,380 @@ +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 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< + | 'loading' + | 'select-project' + | 'select-source' + | 'select-target' + | 'migrating' + | 'done' + | 'error' + >('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(); + + // When migration is done and stats are set, call the onComplete callback and exit the process + useEffect(() => { + if (state === 'done' && migrationStats) { + // Print final summary to console for debug + console.log('Migration completed. Final results:'); + console.log( + `Users: ${migrationStats.users?.success || 0}/${migrationStats.users?.total || 0}`, + ); + console.log( + `Role Assignments: ${migrationStats.roleAssignments?.success || 0}/${migrationStats.roleAssignments?.total || 0}`, + ); + + if (onComplete) { + onComplete(); + } else { + // Ensure we exit after showing the completion message for 2 seconds + setTimeout(() => { + console.log('Exiting process...'); + process.exit(0); + }, 2000); + } + } else if (state === 'error') { + // Exit with error code after a short delay + setTimeout(() => { + console.log('Exiting with error...'); + process.exit(1); + }, 2000); + } + }, [state, migrationStats, onComplete]); + + // Load projects if needed + useEffect(() => { + const fetchProjects = async () => { + // If project_id is already in scope, use it directly + if (scope.project_id) { + setActiveProject(scope.project_id); + setState('select-source'); + return; + } + + try { + const { data: projectsData, error: projectsError } = + await getProjects(); + + if (projectsError) { + setError(`Failed to load projects: ${projectsError}`); + setState('error'); + return; + } + + if (!projectsData || projectsData.length === 0) { + setError('No projects found'); + setState('error'); + return; + } + + if (projectsData.length === 1 && projectsData[0]) { + setActiveProject(projectsData[0].id); + setState('select-source'); + } else { + setProjects( + projectsData.map(project => ({ + label: project.name, + value: project.id, + })), + ); + setState('select-project'); + } + } catch (err) { + setError( + 'Error loading projects: ' + + (err instanceof Error ? err.message : 'Unknown error'), + ); + setState('error'); + } + }; + + fetchProjects(); + }, [getProjects, scope.project_id]); + + // Load environments + useEffect(() => { + const fetchEnvironments = async () => { + if (!activeProject) return; + + try { + const { data: environmentsData, error: environmentsError } = + await getEnvironments(activeProject); + + if (environmentsError) { + setError(`Failed to load environments: ${environmentsError}`); + setState('error'); + return; + } + + if (!environmentsData || environmentsData.length < 2) { + setError('You need at least two environments to perform migration'); + setState('error'); + return; + } + + setEnvironments( + environmentsData.map(env => ({ + label: env.name, + value: env.id, + })), + ); + + // If source is provided and valid, select it + if (source) { + const sourceEnvironment = environmentsData.find( + env => env.id === source || env.key === source, + ); + + if (sourceEnvironment) { + setSourceEnv(sourceEnvironment.id); + setState('select-target'); + } else if (state === 'loading') { + setState('select-source'); + } + } else if (state === 'loading') { + setState('select-source'); + } + + // If target is provided and valid, select it + if (target && sourceEnv) { + const targetEnvironment = environmentsData.find( + env => env.id === target || env.key === target, + ); + + if (targetEnvironment && targetEnvironment.id !== sourceEnv) { + setTargetEnv(targetEnvironment.id); + setState('migrating'); + } + } + } catch (err) { + setError( + 'Error loading environments: ' + + (err instanceof Error ? err.message : 'Unknown error'), + ); + setState('error'); + } + }; + + if (activeProject && (state === 'select-source' || state === 'loading')) { + fetchEnvironments(); + } + }, [activeProject, getEnvironments, source, state, target, sourceEnv]); + + // Perform migration + useEffect(() => { + const performMigration = async () => { + if (sourceEnv && targetEnv && state === 'migrating') { + try { + let userStats = { total: 0, success: 0, failed: 0 }; + let roleAssignmentStats = { total: 0, success: 0, failed: 0 }; + + console.log(`DEBUG - Using project ID: ${scope.project_id}`); + console.log( + `DEBUG - Source env: ${sourceEnv}, Target env: ${targetEnv}`, + ); + + // Migrate users first (these are required for role assignments) + try { + console.log('Migrating users...'); + userStats = await migrateUsers( + sourceEnv, + targetEnv, + conflictStrategy, + ); + + // Add a small delay to ensure users are completely processed + console.log('Waiting for user data to settle...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (userError) { + console.error( + 'Error during user migration:', + userError instanceof Error ? userError.message : 'Unknown error', + ); + } + + // Migrate role assignments + try { + console.log('Migrating role assignments...'); + roleAssignmentStats = await migrateRoleAssignments( + sourceEnv, + targetEnv, + conflictStrategy, + ); + } catch (assignmentError) { + console.error( + 'Error during role assignment migration:', + assignmentError instanceof Error + ? assignmentError.message + : 'Unknown error', + ); + } + + setMigrationStats({ + users: userStats, + roleAssignments: roleAssignmentStats, + }); + + setState('done'); + } catch (err) { + setError( + 'Migration failed: ' + + (err instanceof Error ? err.message : 'Unknown error'), + ); + setState('error'); + } + } + }; + + performMigration(); + }, [ + sourceEnv, + targetEnv, + state, + migrateUsers, + migrateRoleAssignments, + conflictStrategy, + scope.project_id, + ]); + + const handleProjectSelect = useCallback((item: { value: string }) => { + setActiveProject(item.value); + setState('select-source'); + }, []); + + const handleSourceSelect = useCallback((item: { value: string }) => { + setSourceEnv(item.value); + setState('select-target'); + }, []); + + const handleTargetSelect = useCallback( + (item: { value: string }) => { + // Make sure target is different from source + if (item.value !== sourceEnv) { + setTargetEnv(item.value); + setState('migrating'); + } else { + setError('Source and target environments cannot be the same'); + } + }, + [sourceEnv], + ); + + return ( + + {state === 'loading' && ( + + Loading... + + )} + + {state === 'select-project' && ( + <> + Select project: + + + )} + + {state === 'select-source' && ( + <> + Select source environment: + + + )} + + {state === 'select-target' && sourceEnv && ( + <> + Select target environment: + env.value !== sourceEnv)} + onSelect={handleTargetSelect} + /> + + )} + + {state === 'migrating' && ( + + Migrating data from{' '} + {environments.find(e => e.value === sourceEnv)?.label} to{' '} + {environments.find(e => e.value === targetEnv)?.label}... + + )} + + {state === 'done' && migrationStats && ( + <> + Migration completed successfully! + + Migration Summary: + + Users: {migrationStats.users?.success || 0}/ + {migrationStats.users?.total || 0} migrated successfully + + + Role Assignments: {migrationStats.roleAssignments?.success || 0}/ + {migrationStats.roleAssignments?.total || 0} migrated successfully + + + {/* Warning messages */} + {(migrationStats.users?.failed || 0) > 0 && ( + + Warning: {migrationStats.users?.failed} users failed to migrate + + )} + {(migrationStats.roleAssignments?.failed || 0) > 0 && ( + + Warning: {migrationStats.roleAssignments?.failed} role + assignments failed to migrate + + )} + + + )} + + {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..05bb921c --- /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]: any }; + role: string | { key: string; [key: string]: any }; + tenant?: string | { key: string; [key: string]: any }; + 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..14b7dd52 --- /dev/null +++ b/source/hooks/migration/useMigrateResources.ts @@ -0,0 +1,236 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { + MigrationStats, + Resource, + ResourceAction, + ResourceAttribute, + ConflictStrategy, +} from './types.js'; + +const useMigrateResources = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + /** + * Clean up resource actions by removing metadata fields not accepted by the API + */ + const cleanResourceData = (resource: Resource) => { + const cleanedResource: Record = { + key: resource.key, + name: resource.name || resource.key, + description: resource.description || '', + actions: {} as Record, + attributes: {} as Record, + }; + + if (resource.actions) { + Object.keys(resource.actions).forEach(actionKey => { + const action = resource.actions?.[actionKey]; + if (action) { + cleanedResource.actions[actionKey] = { + name: action.name, + description: action.description || '', + }; + } + }); + } + + if (resource.attributes) { + Object.keys(resource.attributes).forEach(attrKey => { + const attr = resource.attributes?.[attrKey]; + if (attr) { + cleanedResource.attributes[attrKey] = { + type: attr.type, + }; + if (attr.description) { + cleanedResource.attributes[attrKey].description = attr.description; + } + } + }); + } + + return cleanedResource; + }; + + const migrateResources = 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 is not available in the current scope'); + } + + // Get resources from source environment + const { data: sourceResourcesResponse, error: sourceResourcesError } = + await authenticatedApiClient().GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + { + proj_id: scope.project_id, + env_id: sourceEnvId, + }, + ); + + if (sourceResourcesError) { + stats.details?.push( + `Error getting resources: ${sourceResourcesError}`, + ); + return stats; + } + + if (!sourceResourcesResponse) { + stats.details?.push('No resources found in source environment'); + return stats; + } + + // Handle different response formats + const sourceResources = Array.isArray(sourceResourcesResponse) + ? sourceResourcesResponse + : sourceResourcesResponse && + typeof sourceResourcesResponse === 'object' && + 'data' in sourceResourcesResponse && + Array.isArray(sourceResourcesResponse.data) + ? sourceResourcesResponse.data + : []; + + // Get resources from target environment + const { data: targetResourcesResponse } = + await authenticatedApiClient().GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + }, + ); + + // Handle different response formats + const targetResources = targetResourcesResponse + ? Array.isArray(targetResourcesResponse) + ? targetResourcesResponse + : targetResourcesResponse && + typeof targetResourcesResponse === 'object' && + 'data' in targetResourcesResponse && + Array.isArray(targetResourcesResponse.data) + ? targetResourcesResponse.data + : [] + : []; + + // Create a map of existing resource keys in target + const targetResourceKeys = new Set( + targetResources.map(resource => resource?.key).filter(Boolean), + ); + + stats.total = sourceResources.length; + + // Create or update each resource + for (let i = 0; i < sourceResources.length; i++) { + const resource = sourceResources[i]; + + try { + if (!resource?.key) { + stats.failed++; + continue; + } + + // Clean up the resource data to remove fields not accepted by the API + const resourceData = cleanResourceData(resource); + + // Check if resource already exists in target + if (targetResourceKeys.has(resource.key)) { + if (conflictStrategy === 'override') { + try { + // Use PUT for updating resources + const updateResult = await authenticatedApiClient().PUT( + `/v2/schema/{proj_id}/{env_id}/resources/{resource_id}`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + resource_id: resource.key, + }, + resourceData, + undefined, + ); + + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Failed to update resource ${resource.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Error updating resource ${resource.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } else { + stats.failed++; + stats.details?.push( + `Resource ${resource.key} already exists (conflict=fail)`, + ); + } + } else { + // Create the resource + try { + const createResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/resources`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + }, + resourceData, + undefined, + ); + + if (createResult.error) { + stats.failed++; + stats.details?.push( + `Failed to create resource ${resource.key}: ${createResult.error}`, + ); + } else { + stats.success++; + targetResourceKeys.add(resource.key); + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Error creating resource ${resource.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + } catch (resourceError) { + stats.failed++; + stats.details?.push( + `Error processing resource: ${resourceError instanceof Error ? resourceError.message : 'Unknown error'}`, + ); + } + } + + return stats; + } catch (err) { + stats.details?.push( + `Resource migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + 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..8fa3c8ed --- /dev/null +++ b/source/hooks/migration/useMigrateRoleAssignments.ts @@ -0,0 +1,203 @@ +// hooks/migration/useMigrateRoleAssignments.ts +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, RoleAssignment, ConflictStrategy } from './types.js'; +import useMigrateRoles from './useMigrateRoles.js'; + +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('Project ID is not available in the current scope'); + } + + // Get role assignments + const { data: roleAssignmentsResponse, error: roleAssignmentsError } = + await authenticatedApiClient().GET( + `/v2/facts/{proj_id}/{env_id}/role_assignments`, + { + proj_id: scope.project_id, + env_id: sourceEnvId, + }, + undefined, + { per_page: 100 }, + ); + + if (roleAssignmentsError) { + stats.details?.push( + `Error getting role assignments: ${roleAssignmentsError}`, + ); + return stats; + } + + if (!roleAssignmentsResponse) { + stats.details?.push( + 'No role assignments found in source environment', + ); + 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); + + // 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`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + }, + { + key: roleKey, + name: roleKey, + description: `Auto-created role during migration`, + }, + undefined, + ); + + if (!createRoleResult.error) { + validRoleKeys.add(roleKey); + } + } catch (error) { + // Continue with assignment attempt even if role creation fails + } + } + + // Create assignment object with proper typing + const assignmentData: Record = { + user: + typeof assignment.user === 'object' + ? assignment.user.key + : String(assignment.user), + role: roleKey, + tenant: + typeof assignment.tenant === 'object' + ? assignment.tenant.key + : String(assignment.tenant || 'default'), + }; + + try { + const createResult = await authenticatedApiClient().POST( + `/v2/facts/{proj_id}/{env_id}/role_assignments`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + }, + assignmentData, + undefined, + ); + + if (createResult.error) { + // Handle specific error cases + if ( + createResult.error.includes && + (createResult.error.includes("could not find 'Role'") || + createResult.error.includes('role does not exist')) + ) { + stats.failed++; + stats.details?.push( + `Failed to assign role ${roleKey}: 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( + `Failed to assign role ${roleKey}: ${createResult.error}`, + ); + } + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Error creating assignment: ${error instanceof Error ? error.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( + `Role assignment migration error: ${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..e2de1a51 --- /dev/null +++ b/source/hooks/migration/useMigrateRoles.ts @@ -0,0 +1,220 @@ +// hooks/migration/useMigrateRoles.ts +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, Role, ConflictStrategy } from './types.js'; + +const useMigrateRoles = () => { + const { authenticatedApiClient } = useClient(); + const { scope } = useAuth(); + + /** + * Gets a list of roles from the environment + */ + const getRoles = useCallback( + async (environmentId: string) => { + if (!scope.project_id) { + return { + roles: [], + error: 'Project ID is not available in the current scope', + }; + } + + // Try known roles API endpoints + const rolesEndpoint = `/v2/schema/{proj_id}/{env_id}/roles`; + + try { + const { data: rolesResponse, error } = + await authenticatedApiClient().GET(rolesEndpoint, { + proj_id: scope.project_id, + env_id: environmentId, + }); + + if (error) { + return { roles: [], error }; + } + + if (rolesResponse) { + // Handle different response formats + const roles = Array.isArray(rolesResponse) + ? rolesResponse + : rolesResponse && + typeof rolesResponse === 'object' && + 'data' in rolesResponse && + Array.isArray(rolesResponse.data) + ? rolesResponse.data + : []; + + if (roles.length > 0) { + return { roles, error: null }; + } + } + + return { roles: [], error: 'No roles found' }; + } catch (error) { + return { + roles: [], + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + [authenticatedApiClient, scope.project_id], + ); + + const migrateRoles = 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 is not available in the current scope'); + } + + // Get roles from source environment + const { roles: sourceRoles, error: sourceRolesError } = + await getRoles(sourceEnvId); + + if (sourceRolesError) { + stats.details?.push(`Error getting roles: ${sourceRolesError}`); + return stats; + } + + if (!sourceRoles || sourceRoles.length === 0) { + stats.details?.push('No roles found in source environment'); + return stats; + } + + // Get roles from target environment + const { roles: targetRoles } = await getRoles(targetEnvId); + + // Create a map of existing role keys in target + const targetRoleKeys = new Set( + targetRoles?.map(role => role?.key).filter(Boolean), + ); + + stats.total = sourceRoles.length; + + // Create or update each role + for (let i = 0; i < sourceRoles.length; i++) { + const role = sourceRoles[i]; + + try { + if (!role?.key) { + stats.failed++; + continue; + } + + // Create minimal role object with required fields + const roleData: Role = { + key: role.key, + name: role.name || role.key, + description: role.description || '', + permissions: role.permissions || [], + }; + + // Check if role already exists in target + if (targetRoleKeys.has(role.key)) { + if (conflictStrategy === 'override') { + try { + // Use PUT for update + const updateResult = await authenticatedApiClient().PUT( + `/v2/schema/{proj_id}/{env_id}/roles/{role_id}`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + role_id: role.key, + }, + roleData, + undefined, + ); + + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Failed to update role ${role.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Error updating role ${role.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } else { + stats.failed++; + stats.details?.push( + `Role ${role.key} already exists (conflict=fail)`, + ); + } + } else { + // Create the role + try { + const createResult = await authenticatedApiClient().POST( + `/v2/schema/{proj_id}/{env_id}/roles`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + }, + roleData, + undefined, + ); + + if (createResult.error) { + stats.failed++; + if ( + createResult.error.includes && + createResult.error.includes('MISSING_PERMISSIONS') + ) { + stats.details?.push( + `Role ${role.key} requires missing permissions. Consider migrating resources first.`, + ); + } else { + stats.details?.push( + `Failed to create role ${role.key}: ${createResult.error}`, + ); + } + } else { + stats.success++; + targetRoleKeys.add(role.key); + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Error creating role ${role.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + } catch (roleError) { + stats.failed++; + stats.details?.push( + `Error processing role: ${roleError instanceof Error ? roleError.message : 'Unknown error'}`, + ); + } + } + + return stats; + } catch (err) { + stats.details?.push( + `Role migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + 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..798dacd7 --- /dev/null +++ b/source/hooks/migration/useMigrateUsers.ts @@ -0,0 +1,151 @@ +import { useCallback } from 'react'; +import useClient from '../useClient.js'; +import { useAuth } from '../../components/AuthProvider.js'; +import { MigrationStats, User, ConflictStrategy } from './types.js'; + +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 is not available in the current scope'); + } + + // Get all users in one call + const { data: sourceUsersResponse } = + await authenticatedApiClient().GET( + `/v2/facts/{proj_id}/{env_id}/users`, + { + proj_id: scope.project_id, + env_id: sourceEnvId, + }, + undefined, + { per_page: 100 }, + ); + + if (!sourceUsersResponse) { + stats.details?.push('No users found in source environment'); + return stats; + } + + // Handle different response formats + const users = Array.isArray(sourceUsersResponse) + ? sourceUsersResponse + : sourceUsersResponse && + typeof sourceUsersResponse === 'object' && + 'data' in sourceUsersResponse && + Array.isArray(sourceUsersResponse.data) + ? sourceUsersResponse.data + : []; + + stats.total = users.length; + + // Process each user + for (let i = 0; i < users.length; i++) { + const user = users[i]; + + try { + if (!user || !user.key) { + stats.failed++; + continue; + } + + // Create a minimal user object with only required fields + const userData: User = { + key: user.key, + email: user.email || undefined, + first_name: user.first_name || undefined, + last_name: user.last_name || undefined, + attributes: user.attributes || {}, + }; + + // Create the user in target + try { + const createResult = await authenticatedApiClient().POST( + `/v2/facts/{proj_id}/{env_id}/users`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + }, + userData, + undefined, + ); + + if (createResult.error) { + if ( + createResult.error.includes && + createResult.error.includes('already exists') && + conflictStrategy === 'override' + ) { + // Try to update instead + const updateResult = await authenticatedApiClient().PUT( + `/v2/facts/{proj_id}/{env_id}/users/{user_id}`, + { + proj_id: scope.project_id, + env_id: targetEnvId, + user_id: user.key, + }, + userData, + undefined, + ); + + if (updateResult.error) { + stats.failed++; + stats.details?.push( + `Failed to update user ${user.key}: ${updateResult.error}`, + ); + } else { + stats.success++; + } + } else { + stats.failed++; + stats.details?.push( + `Failed to create user ${user.key}: ${createResult.error}`, + ); + } + } else { + stats.success++; + } + } catch (error) { + stats.failed++; + stats.details?.push( + `Error creating user ${user.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } catch (userError) { + stats.failed++; + stats.details?.push( + `Error processing user: ${userError instanceof Error ? userError.message : 'Unknown error'}`, + ); + } + } + + return stats; + } catch (err) { + stats.details?.push( + `User migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + 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..67940e43 --- /dev/null +++ b/source/hooks/useDataMigration.tsx @@ -0,0 +1,21 @@ +// hooks/useDataMigration.tsx +import useMigrateResources from './migration/useMigrateResources.js'; +import useMigrateUsers from './migration/useMigrateUsers.js'; +import useMigrateRoles from './migration/useMigrateRoles.js'; +import useMigrateRoleAssignments from './migration/useMigrateRoleAssignments.js'; + +export const useDataMigration = () => { + const { migrateResources } = useMigrateResources(); + const { migrateUsers } = useMigrateUsers(); + const { migrateRoles } = useMigrateRoles(); + const { migrateRoleAssignments } = useMigrateRoleAssignments(); + + return { + migrateUsers, + migrateRoleAssignments, + migrateRoles, + migrateResources, + }; +}; + +export default useDataMigration; From 2fdc1212ca6e873f50199ceb70abdd9363857b50 Mon Sep 17 00:00:00 2001 From: daveads Date: Fri, 2 May 2025 13:30:34 +0100 Subject: [PATCH 2/3] build --- .../components/env/DataMigrationComponent.tsx | 293 ++++++++---------- source/hooks/migration/types.ts | 14 +- source/hooks/migration/useMigrateResources.ts | 280 ++++++++--------- .../migration/useMigrateRoleAssignments.ts | 65 ++-- source/hooks/migration/useMigrateRoles.ts | 162 ++++------ source/hooks/migration/useMigrateUsers.ts | 120 +++---- source/hooks/useDataMigration.tsx | 114 ++++++- 7 files changed, 549 insertions(+), 499 deletions(-) diff --git a/source/components/env/DataMigrationComponent.tsx b/source/components/env/DataMigrationComponent.tsx index 84c8ef13..37016632 100644 --- a/source/components/env/DataMigrationComponent.tsx +++ b/source/components/env/DataMigrationComponent.tsx @@ -17,6 +17,23 @@ type Props = { 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, @@ -32,15 +49,7 @@ const DataMigrationComponent: React.FC = ({ const [environments, setEnvironments] = useState([]); const [projects, setProjects] = useState([]); const [activeProject, setActiveProject] = useState(null); - const [state, setState] = useState< - | 'loading' - | 'select-project' - | 'select-source' - | 'select-target' - | 'migrating' - | 'done' - | 'error' - >('loading'); + const [state, setState] = useState(MIGRATION_STATE.LOADING); const [error, setError] = useState(null); const [migrationStats, setMigrationStats] = useState<{ users: { total: number; success: number; failed: number }; @@ -49,29 +58,26 @@ const DataMigrationComponent: React.FC = ({ const { migrateUsers, migrateRoleAssignments } = useDataMigration(); - // When migration is done and stats are set, call the onComplete callback and exit the process + // Handle completion & exit useEffect(() => { - if (state === 'done' && migrationStats) { - // Print final summary to console for debug + if (state === MIGRATION_STATE.DONE && migrationStats) { console.log('Migration completed. Final results:'); console.log( - `Users: ${migrationStats.users?.success || 0}/${migrationStats.users?.total || 0}`, + `Users: ${migrationStats.users.success}/${migrationStats.users.total}`, ); console.log( - `Role Assignments: ${migrationStats.roleAssignments?.success || 0}/${migrationStats.roleAssignments?.total || 0}`, + `Role Assignments: ${migrationStats.roleAssignments.success}/${migrationStats.roleAssignments.total}`, ); - if (onComplete) { onComplete(); } else { - // Ensure we exit after showing the completion message for 2 seconds setTimeout(() => { console.log('Exiting process...'); process.exit(0); }, 2000); } - } else if (state === 'error') { - // Exit with error code after a short delay + } + if (state === MIGRATION_STATE.ERROR) { setTimeout(() => { console.log('Exiting with error...'); process.exit(1); @@ -79,53 +85,42 @@ const DataMigrationComponent: React.FC = ({ } }, [state, migrationStats, onComplete]); - // Load projects if needed + // Load projects useEffect(() => { const fetchProjects = async () => { - // If project_id is already in scope, use it directly if (scope.project_id) { setActiveProject(scope.project_id); - setState('select-source'); + setState(MIGRATION_STATE.SELECT_SOURCE); return; } - try { const { data: projectsData, error: projectsError } = await getProjects(); - if (projectsError) { setError(`Failed to load projects: ${projectsError}`); - setState('error'); + setState(MIGRATION_STATE.ERROR); return; } - if (!projectsData || projectsData.length === 0) { setError('No projects found'); - setState('error'); + setState(MIGRATION_STATE.ERROR); return; } - - if (projectsData.length === 1 && projectsData[0]) { - setActiveProject(projectsData[0].id); - setState('select-source'); + if (projectsData.length === 1) { + setActiveProject(projectsData[0]!.id); + setState(MIGRATION_STATE.SELECT_SOURCE); } else { - setProjects( - projectsData.map(project => ({ - label: project.name, - value: project.id, - })), - ); - setState('select-project'); + setProjects(projectsData.map(p => ({ label: p.name, value: p.id }))); + setState(MIGRATION_STATE.SELECT_PROJECT); } - } catch (err) { + } catch (e) { setError( 'Error loading projects: ' + - (err instanceof Error ? err.message : 'Unknown error'), + (e instanceof Error ? e.message : UNKNOWN_ERROR), ); - setState('error'); + setState(MIGRATION_STATE.ERROR); } }; - fetchProjects(); }, [getProjects, scope.project_id]); @@ -133,67 +128,57 @@ const DataMigrationComponent: React.FC = ({ useEffect(() => { const fetchEnvironments = async () => { if (!activeProject) return; - try { - const { data: environmentsData, error: environmentsError } = + const { data: envData, error: envError } = await getEnvironments(activeProject); - - if (environmentsError) { - setError(`Failed to load environments: ${environmentsError}`); - setState('error'); + if (envError) { + setError(`Failed to load environments: ${envError}`); + setState(MIGRATION_STATE.ERROR); return; } - - if (!environmentsData || environmentsData.length < 2) { + if (!envData || envData.length < 2) { setError('You need at least two environments to perform migration'); - setState('error'); + setState(MIGRATION_STATE.ERROR); return; } - setEnvironments( - environmentsData.map(env => ({ - label: env.name, - value: env.id, - })), + envData.map(env => ({ label: env.name, value: env.id })), ); - - // If source is provided and valid, select it if (source) { - const sourceEnvironment = environmentsData.find( - env => env.id === source || env.key === source, - ); - - if (sourceEnvironment) { - setSourceEnv(sourceEnvironment.id); - setState('select-target'); - } else if (state === 'loading') { - setState('select-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 === 'loading') { - setState('select-source'); + } else if (state === MIGRATION_STATE.LOADING) { + setState(MIGRATION_STATE.SELECT_SOURCE); } - - // If target is provided and valid, select it if (target && sourceEnv) { - const targetEnvironment = environmentsData.find( - env => env.id === target || env.key === target, - ); - - if (targetEnvironment && targetEnvironment.id !== sourceEnv) { - setTargetEnv(targetEnvironment.id); - setState('migrating'); + const found = envData.find(e => e.id === target || e.key === target); + if (found && found.id !== sourceEnv) { + setTargetEnv(found.id); + setState(MIGRATION_STATE.MIGRATING); } } - } catch (err) { + } catch (e) { setError( 'Error loading environments: ' + - (err instanceof Error ? err.message : 'Unknown error'), + (e instanceof Error ? e.message : UNKNOWN_ERROR), ); - setState('error'); + setState(MIGRATION_STATE.ERROR); } }; - - if (activeProject && (state === 'select-source' || state === 'loading')) { + if ( + activeProject && + ( + [ + MIGRATION_STATE.SELECT_SOURCE, + MIGRATION_STATE.LOADING, + ] as MigrationState[] + ).includes(state) + ) { fetchEnvironments(); } }, [activeProject, getEnvironments, source, state, target, sourceEnv]); @@ -201,73 +186,55 @@ const DataMigrationComponent: React.FC = ({ // Perform migration useEffect(() => { const performMigration = async () => { - if (sourceEnv && targetEnv && state === 'migrating') { + 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 { - let userStats = { total: 0, success: 0, failed: 0 }; - let roleAssignmentStats = { total: 0, success: 0, failed: 0 }; - - console.log(`DEBUG - Using project ID: ${scope.project_id}`); - console.log( - `DEBUG - Source env: ${sourceEnv}, Target env: ${targetEnv}`, + console.log('Migrating users...'); + userStats = await migrateUsers( + sourceEnv, + targetEnv, + conflictStrategy, ); - - // Migrate users first (these are required for role assignments) - try { - console.log('Migrating users...'); - userStats = await migrateUsers( - sourceEnv, - targetEnv, - conflictStrategy, - ); - - // Add a small delay to ensure users are completely processed - console.log('Waiting for user data to settle...'); - await new Promise(resolve => setTimeout(resolve, 1000)); - } catch (userError) { - console.error( - 'Error during user migration:', - userError instanceof Error ? userError.message : 'Unknown error', - ); - } - - // Migrate role assignments - try { - console.log('Migrating role assignments...'); - roleAssignmentStats = await migrateRoleAssignments( - sourceEnv, - targetEnv, - conflictStrategy, - ); - } catch (assignmentError) { - console.error( - 'Error during role assignment migration:', - assignmentError instanceof Error - ? assignmentError.message - : 'Unknown error', - ); - } - - setMigrationStats({ - users: userStats, - roleAssignments: roleAssignmentStats, - }); - - setState('done'); - } catch (err) { - setError( - 'Migration failed: ' + - (err instanceof Error ? err.message : 'Unknown error'), + 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, ); - setState('error'); + } 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, - state, migrateUsers, migrateRoleAssignments, conflictStrategy, @@ -276,22 +243,21 @@ const DataMigrationComponent: React.FC = ({ const handleProjectSelect = useCallback((item: { value: string }) => { setActiveProject(item.value); - setState('select-source'); + setState(MIGRATION_STATE.SELECT_SOURCE); }, []); const handleSourceSelect = useCallback((item: { value: string }) => { setSourceEnv(item.value); - setState('select-target'); + setState(MIGRATION_STATE.SELECT_TARGET); }, []); const handleTargetSelect = useCallback( (item: { value: string }) => { - // Make sure target is different from source - if (item.value !== sourceEnv) { - setTargetEnv(item.value); - setState('migrating'); + if (item.value === sourceEnv) { + setError(`${WARNING_TEXT} ${SAME_ENV_ERROR}`); } else { - setError('Source and target environments cannot be the same'); + setTargetEnv(item.value); + setState(MIGRATION_STATE.MIGRATING); } }, [sourceEnv], @@ -299,37 +265,37 @@ const DataMigrationComponent: React.FC = ({ return ( - {state === 'loading' && ( + {state === MIGRATION_STATE.LOADING && ( Loading... )} - {state === 'select-project' && ( + {state === MIGRATION_STATE.SELECT_PROJECT && ( <> Select project: )} - {state === 'select-source' && ( + {state === MIGRATION_STATE.SELECT_SOURCE && ( <> Select source environment: )} - {state === 'select-target' && sourceEnv && ( + {state === MIGRATION_STATE.SELECT_TARGET && sourceEnv && ( <> Select target environment: env.value !== sourceEnv)} + items={environments.filter(e => e.value !== sourceEnv)} onSelect={handleTargetSelect} /> )} - {state === 'migrating' && ( + {state === MIGRATION_STATE.MIGRATING && ( Migrating data from{' '} {environments.find(e => e.value === sourceEnv)?.label} to{' '} @@ -337,29 +303,28 @@ const DataMigrationComponent: React.FC = ({ )} - {state === 'done' && migrationStats && ( + {state === MIGRATION_STATE.DONE && migrationStats && ( <> Migration completed successfully! Migration Summary: - Users: {migrationStats.users?.success || 0}/ - {migrationStats.users?.total || 0} migrated successfully + Users: {migrationStats.users.success}/{migrationStats.users.total}{' '} + migrated successfully - Role Assignments: {migrationStats.roleAssignments?.success || 0}/ - {migrationStats.roleAssignments?.total || 0} migrated successfully + Role Assignments: {migrationStats.roleAssignments.success}/ + {migrationStats.roleAssignments.total} migrated successfully - - {/* Warning messages */} - {(migrationStats.users?.failed || 0) > 0 && ( + {migrationStats.users.failed > 0 && ( - Warning: {migrationStats.users?.failed} users failed to migrate + {WARNING_TEXT} {migrationStats.users.failed} users failed to + migrate )} - {(migrationStats.roleAssignments?.failed || 0) > 0 && ( + {migrationStats.roleAssignments.failed > 0 && ( - Warning: {migrationStats.roleAssignments?.failed} role + {WARNING_TEXT} {migrationStats.roleAssignments.failed} role assignments failed to migrate )} @@ -367,7 +332,7 @@ const DataMigrationComponent: React.FC = ({ )} - {state === 'error' && error && ( + {state === MIGRATION_STATE.ERROR && error && ( <> Error: {error} Process will exit in a few seconds... diff --git a/source/hooks/migration/types.ts b/source/hooks/migration/types.ts index 05bb921c..e5a2e624 100644 --- a/source/hooks/migration/types.ts +++ b/source/hooks/migration/types.ts @@ -19,8 +19,8 @@ export interface Resource { key: string; name?: string; description?: string; - actions?: Record; - attributes?: Record; + actions?: Record; + attributes?: Record; } export interface User { @@ -28,21 +28,21 @@ export interface User { email?: string; first_name?: string; last_name?: string; - attributes?: Record; + attributes?: Record; } export interface Role { key: string; - name?: string; + name: string; description?: string; permissions?: string[]; resource?: string; } export interface RoleAssignment { - user: string | { key: string; [key: string]: any }; - role: string | { key: string; [key: string]: any }; - tenant?: string | { key: string; [key: string]: any }; + user: string | { key: string; [key: string]: unknown }; + role: string | { key: string; [key: string]: unknown }; + tenant?: string | { key: string; [key: string]: unknown }; resource_instance?: string; } diff --git a/source/hooks/migration/useMigrateResources.ts b/source/hooks/migration/useMigrateResources.ts index 14b7dd52..9968dc10 100644 --- a/source/hooks/migration/useMigrateResources.ts +++ b/source/hooks/migration/useMigrateResources.ts @@ -1,65 +1,74 @@ import { useCallback } from 'react'; import useClient from '../useClient.js'; import { useAuth } from '../../components/AuthProvider.js'; -import { - MigrationStats, - Resource, - ResourceAction, - ResourceAttribute, - ConflictStrategy, -} from './types.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(); - /** - * Clean up resource actions by removing metadata fields not accepted by the API - */ - const cleanResourceData = (resource: Resource) => { - const cleanedResource: Record = { - key: resource.key, - name: resource.name || resource.key, - description: resource.description || '', - actions: {} as Record, - attributes: {} as Record, - }; - - if (resource.actions) { - Object.keys(resource.actions).forEach(actionKey => { - const action = resource.actions?.[actionKey]; - if (action) { - cleanedResource.actions[actionKey] = { - name: action.name, - description: action.description || '', - }; - } - }); - } - - if (resource.attributes) { - Object.keys(resource.attributes).forEach(attrKey => { - const attr = resource.attributes?.[attrKey]; - if (attr) { - cleanedResource.attributes[attrKey] = { - type: attr.type, - }; - if (attr.description) { - cleanedResource.attributes[attrKey].description = attr.description; - } - } - }); - } - - return cleanedResource; - }; - 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, @@ -68,162 +77,147 @@ const useMigrateResources = () => { }; try { - if (!scope.project_id) { - throw new Error('Project ID is not available in the current scope'); - } + if (!scope.project_id) throw new Error('Project ID missing'); - // Get resources from source environment - const { data: sourceResourcesResponse, error: sourceResourcesError } = + const { data: sourceResResp, error: sourceErr } = await authenticatedApiClient().GET( `/v2/schema/{proj_id}/{env_id}/resources`, - { - proj_id: scope.project_id, - env_id: sourceEnvId, - }, + { env_id: sourceEnvId }, ); - if (sourceResourcesError) { - stats.details?.push( - `Error getting resources: ${sourceResourcesError}`, - ); + if (sourceErr) { + stats.details?.push(`Get source error: ${sourceErr}`); return stats; } - - if (!sourceResourcesResponse) { - stats.details?.push('No resources found in source environment'); + if (!sourceResResp) { + stats.details?.push('No source resources'); return stats; } - // Handle different response formats - const sourceResources = Array.isArray(sourceResourcesResponse) - ? sourceResourcesResponse - : sourceResourcesResponse && - typeof sourceResourcesResponse === 'object' && - 'data' in sourceResourcesResponse && - Array.isArray(sourceResourcesResponse.data) - ? sourceResourcesResponse.data - : []; - - // Get resources from target environment - const { data: targetResourcesResponse } = - await authenticatedApiClient().GET( - `/v2/schema/{proj_id}/{env_id}/resources`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - }, - ); - - // Handle different response formats - const targetResources = targetResourcesResponse - ? Array.isArray(targetResourcesResponse) - ? targetResourcesResponse - : targetResourcesResponse && - typeof targetResourcesResponse === 'object' && - 'data' in targetResourcesResponse && - Array.isArray(targetResourcesResponse.data) - ? targetResourcesResponse.data - : [] - : []; + const sourceResources: ReadonlyArray = Array.isArray( + sourceResResp, + ) + ? sourceResResp + : sourceResResp.data || []; - // Create a map of existing resource keys in target - const targetResourceKeys = new Set( - targetResources.map(resource => resource?.key).filter(Boolean), + 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; - // Create or update each resource - for (let i = 0; i < sourceResources.length; i++) { - const resource = sourceResources[i]; - + for (const resource of sourceResources) { try { if (!resource?.key) { stats.failed++; continue; } - // Clean up the resource data to remove fields not accepted by the API - const resourceData = cleanResourceData(resource); + 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 }, + ), + }; - // Check if resource already exists in target if (targetResourceKeys.has(resource.key)) { if (conflictStrategy === 'override') { try { - // Use PUT for updating resources - const updateResult = await authenticatedApiClient().PUT( + const updateResult = await authenticatedApiClient().PATCH( `/v2/schema/{proj_id}/{env_id}/resources/{resource_id}`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - resource_id: resource.key, - }, - resourceData, + { resource_id: resource.key }, + resourceDataForPatch, undefined, ); - if (updateResult.error) { stats.failed++; - stats.details?.push( - `Failed to update resource ${resource.key}: ${updateResult.error}`, - ); + stats.details?.push(`Update error: ${updateResult.error}`); } else { stats.success++; } } catch (error) { stats.failed++; - stats.details?.push( - `Error updating resource ${resource.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + stats.details?.push(`Update exception: ${error}`); } } else { stats.failed++; - stats.details?.push( - `Resource ${resource.key} already exists (conflict=fail)`, - ); + stats.details?.push(`Conflict: ${resource.key}`); } } else { - // Create the resource try { const createResult = await authenticatedApiClient().POST( `/v2/schema/{proj_id}/{env_id}/resources`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - }, - resourceData, + { env_id: targetEnvId }, + resourceDataForPost, undefined, ); - if (createResult.error) { stats.failed++; - stats.details?.push( - `Failed to create resource ${resource.key}: ${createResult.error}`, - ); + stats.details?.push(`Create error: ${createResult.error}`); } else { stats.success++; targetResourceKeys.add(resource.key); } } catch (error) { stats.failed++; - stats.details?.push( - `Error creating resource ${resource.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + stats.details?.push(`Create exception: ${error}`); } } } catch (resourceError) { stats.failed++; - stats.details?.push( - `Error processing resource: ${resourceError instanceof Error ? resourceError.message : 'Unknown error'}`, - ); + stats.details?.push(`Processing error: ${resourceError}`); } } - return stats; } catch (err) { - stats.details?.push( - `Resource migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); + stats.details?.push(`Migration error: ${err}`); return stats; } }, diff --git a/source/hooks/migration/useMigrateRoleAssignments.ts b/source/hooks/migration/useMigrateRoleAssignments.ts index 8fa3c8ed..788abf21 100644 --- a/source/hooks/migration/useMigrateRoleAssignments.ts +++ b/source/hooks/migration/useMigrateRoleAssignments.ts @@ -1,10 +1,20 @@ -// hooks/migration/useMigrateRoleAssignments.ts import { useCallback } from 'react'; import useClient from '../useClient.js'; import { useAuth } from '../../components/AuthProvider.js'; -import { MigrationStats, RoleAssignment, ConflictStrategy } from './types.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(); @@ -25,7 +35,7 @@ const useMigrateRoleAssignments = () => { try { if (!scope.project_id) { - throw new Error('Project ID is not available in the current scope'); + throw new Error(ERROR_NO_PROJECT_ID); } // Get role assignments @@ -33,7 +43,6 @@ const useMigrateRoleAssignments = () => { await authenticatedApiClient().GET( `/v2/facts/{proj_id}/{env_id}/role_assignments`, { - proj_id: scope.project_id, env_id: sourceEnvId, }, undefined, @@ -42,15 +51,13 @@ const useMigrateRoleAssignments = () => { if (roleAssignmentsError) { stats.details?.push( - `Error getting role assignments: ${roleAssignmentsError}`, + `${ERROR_GETTING_ASSIGNMENTS}${roleAssignmentsError}`, ); return stats; } if (!roleAssignmentsResponse) { - stats.details?.push( - 'No role assignments found in source environment', - ); + stats.details?.push(ERROR_NO_ROLE_ASSIGNMENTS); return stats; } @@ -95,6 +102,16 @@ const useMigrateRoleAssignments = () => { ? 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 @@ -102,7 +119,6 @@ const useMigrateRoleAssignments = () => { const createRoleResult = await authenticatedApiClient().POST( `/v2/schema/{proj_id}/{env_id}/roles`, { - proj_id: scope.project_id, env_id: targetEnvId, }, { @@ -110,39 +126,30 @@ const useMigrateRoleAssignments = () => { name: roleKey, description: `Auto-created role during migration`, }, - undefined, ); if (!createRoleResult.error) { validRoleKeys.add(roleKey); } - } catch (error) { + } catch { // Continue with assignment attempt even if role creation fails } } // Create assignment object with proper typing - const assignmentData: Record = { - user: - typeof assignment.user === 'object' - ? assignment.user.key - : String(assignment.user), + const assignmentData = { + user: userKey, role: roleKey, - tenant: - typeof assignment.tenant === 'object' - ? assignment.tenant.key - : String(assignment.tenant || 'default'), + tenant: tenantKey, }; try { const createResult = await authenticatedApiClient().POST( `/v2/facts/{proj_id}/{env_id}/role_assignments`, { - proj_id: scope.project_id, env_id: targetEnvId, }, assignmentData, - undefined, ); if (createResult.error) { @@ -150,11 +157,11 @@ const useMigrateRoleAssignments = () => { if ( createResult.error.includes && (createResult.error.includes("could not find 'Role'") || - createResult.error.includes('role does not exist')) + createResult.error.includes(ERROR_ROLE_DOES_NOT_EXIST)) ) { stats.failed++; stats.details?.push( - `Failed to assign role ${roleKey}: role does not exist`, + `${ERROR_FAILED_TO_ASSIGN}${roleKey}: ${ERROR_ROLE_DOES_NOT_EXIST}`, ); } else if ( createResult.error.includes && @@ -166,22 +173,22 @@ const useMigrateRoleAssignments = () => { } else { stats.failed++; stats.details?.push( - `Failed to assign role ${roleKey}: ${createResult.error}`, + `${ERROR_FAILED_TO_ASSIGN}${roleKey}: ${createResult.error}`, ); } } else { stats.success++; } - } catch (error) { + } catch (createError) { stats.failed++; stats.details?.push( - `Error creating assignment: ${error instanceof Error ? error.message : 'Unknown error'}`, + `${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'}`, + `${ERROR_PROCESSING_ASSIGNMENT}${assignmentError instanceof Error ? assignmentError.message : UNKNOWN_ERROR}`, ); } } @@ -189,7 +196,7 @@ const useMigrateRoleAssignments = () => { return stats; } catch (err) { stats.details?.push( - `Role assignment migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, + `${ERROR_ROLE_ASSIGNMENT}${err instanceof Error ? err.message : UNKNOWN_ERROR}`, ); return stats; } diff --git a/source/hooks/migration/useMigrateRoles.ts b/source/hooks/migration/useMigrateRoles.ts index e2de1a51..5c331364 100644 --- a/source/hooks/migration/useMigrateRoles.ts +++ b/source/hooks/migration/useMigrateRoles.ts @@ -1,61 +1,44 @@ -// hooks/migration/useMigrateRoles.ts import { useCallback } from 'react'; import useClient from '../useClient.js'; import { useAuth } from '../../components/AuthProvider.js'; -import { MigrationStats, Role, ConflictStrategy } from './types.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(); - /** - * Gets a list of roles from the environment - */ const getRoles = useCallback( - async (environmentId: string) => { - if (!scope.project_id) { - return { - roles: [], - error: 'Project ID is not available in the current scope', - }; - } - - // Try known roles API endpoints + 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, { - proj_id: scope.project_id, env_id: environmentId, }); - - if (error) { - return { roles: [], error }; - } - + if (error) return { roles: [], error }; if (rolesResponse) { - // Handle different response formats const roles = Array.isArray(rolesResponse) ? rolesResponse - : rolesResponse && - typeof rolesResponse === 'object' && - 'data' in rolesResponse && - Array.isArray(rolesResponse.data) - ? rolesResponse.data - : []; - + : rolesResponse.data || []; if (roles.length > 0) { - return { roles, error: null }; + return { roles: roles as RoleRead[], error: null }; } } - return { roles: [], error: 'No roles found' }; } catch (error) { - return { - roles: [], - error: error instanceof Error ? error.message : 'Unknown error', - }; + const message = + error instanceof Error + ? error.message + : 'Unknown error getting roles'; + return { roles: [], error: message }; } }, [authenticatedApiClient, scope.project_id], @@ -67,80 +50,78 @@ const useMigrateRoles = () => { 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 is not available in the current scope'); - } + if (!scope.project_id) throw new Error('Project ID missing'); - // Get roles from source environment const { roles: sourceRoles, error: sourceRolesError } = await getRoles(sourceEnvId); - if (sourceRolesError) { - stats.details?.push(`Error getting roles: ${sourceRolesError}`); + stats.details?.push(`Get source roles error: ${sourceRolesError}`); return stats; } - if (!sourceRoles || sourceRoles.length === 0) { - stats.details?.push('No roles found in source environment'); + stats.details?.push('No source roles found'); return stats; } - // Get roles from target environment const { roles: targetRoles } = await getRoles(targetEnvId); - - // Create a map of existing role keys in target const targetRoleKeys = new Set( - targetRoles?.map(role => role?.key).filter(Boolean), + targetRoles?.map(role => role.key).filter(Boolean), ); - stats.total = sourceRoles.length; - // Create or update each role - for (let i = 0; i < sourceRoles.length; i++) { - const role = sourceRoles[i]; - + for (const role of sourceRoles) { try { if (!role?.key) { stats.failed++; continue; } - // Create minimal role object with required fields - const roleData: Role = { + const roleDataForPost: RoleCreate = { key: role.key, name: role.name || role.key, - description: role.description || '', - permissions: role.permissions || [], + 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); - // Check if role already exists in target if (targetRoleKeys.has(role.key)) { if (conflictStrategy === 'override') { try { - // Use PUT for update - const updateResult = await authenticatedApiClient().PUT( - `/v2/schema/{proj_id}/{env_id}/roles/{role_id}`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - role_id: role.key, - }, - roleData, + 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( - `Failed to update role ${role.key}: ${updateResult.error}`, + `Update role error ${role.key}: ${updateResult.error}`, ); } else { stats.success++; @@ -148,42 +129,30 @@ const useMigrateRoles = () => { } catch (error) { stats.failed++; stats.details?.push( - `Error updating role ${role.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Update role exception ${role.key}: ${error}`, ); } } else { stats.failed++; - stats.details?.push( - `Role ${role.key} already exists (conflict=fail)`, - ); + stats.details?.push(`Conflict role: ${role.key}`); } } else { - // Create the role try { const createResult = await authenticatedApiClient().POST( `/v2/schema/{proj_id}/{env_id}/roles`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - }, - roleData, + { env_id: targetEnvId }, + roleDataForPost, undefined, ); - if (createResult.error) { stats.failed++; - if ( - createResult.error.includes && - createResult.error.includes('MISSING_PERMISSIONS') - ) { - stats.details?.push( - `Role ${role.key} requires missing permissions. Consider migrating resources first.`, - ); - } else { - stats.details?.push( - `Failed to create role ${role.key}: ${createResult.error}`, - ); - } + 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); @@ -191,23 +160,20 @@ const useMigrateRoles = () => { } catch (error) { stats.failed++; stats.details?.push( - `Error creating role ${role.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Create role exception ${role.key}: ${error}`, ); } } } catch (roleError) { stats.failed++; stats.details?.push( - `Error processing role: ${roleError instanceof Error ? roleError.message : 'Unknown error'}`, + `Processing role error ${role.key}: ${roleError}`, ); } } - return stats; } catch (err) { - stats.details?.push( - `Role migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); + stats.details?.push(`Role migration error: ${err}`); return stats; } }, diff --git a/source/hooks/migration/useMigrateUsers.ts b/source/hooks/migration/useMigrateUsers.ts index 798dacd7..f79221a4 100644 --- a/source/hooks/migration/useMigrateUsers.ts +++ b/source/hooks/migration/useMigrateUsers.ts @@ -1,7 +1,16 @@ import { useCallback } from 'react'; import useClient from '../useClient.js'; import { useAuth } from '../../components/AuthProvider.js'; -import { MigrationStats, User, ConflictStrategy } from './types.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(); @@ -19,53 +28,56 @@ const useMigrateUsers = () => { failed: 0, details: [], }; - try { - if (!scope.project_id) { - throw new Error('Project ID is not available in the current scope'); - } + if (!scope.project_id) throw new Error('Project ID missing'); - // Get all users in one call const { data: sourceUsersResponse } = await authenticatedApiClient().GET( `/v2/facts/{proj_id}/{env_id}/users`, - { - proj_id: scope.project_id, - env_id: sourceEnvId, - }, + { env_id: sourceEnvId }, undefined, { per_page: 100 }, ); if (!sourceUsersResponse) { - stats.details?.push('No users found in source environment'); + stats.details?.push('No source users'); return stats; } - // Handle different response formats - const users = Array.isArray(sourceUsersResponse) - ? sourceUsersResponse - : sourceUsersResponse && - typeof sourceUsersResponse === 'object' && - 'data' in sourceUsersResponse && - Array.isArray(sourceUsersResponse.data) - ? sourceUsersResponse.data - : []; + const users: ReadonlyArray = + Array.isArray(sourceUsersResponse) + ? sourceUsersResponse + : sourceUsersResponse.data || []; stats.total = users.length; - // Process each user + // 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; } - // Create a minimal user object with only required fields - const userData: User = { + // Construct payload matching UserCreate schema + const userData: components['schemas']['UserCreate'] = { key: user.key, email: user.email || undefined, first_name: user.first_name || undefined, @@ -73,48 +85,50 @@ const useMigrateUsers = () => { attributes: user.attributes || {}, }; - // Create the user in target try { - const createResult = await authenticatedApiClient().POST( + const createResult = await post( `/v2/facts/{proj_id}/{env_id}/users`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - }, + { env_id: targetEnvId }, userData, undefined, ); if (createResult.error) { + const errorMessage = + typeof createResult.error === 'string' + ? createResult.error + : JSON.stringify(createResult.error); + if ( - createResult.error.includes && - createResult.error.includes('already exists') && + errorMessage.includes('already exists') && conflictStrategy === 'override' ) { - // Try to update instead - const updateResult = await authenticatedApiClient().PUT( - `/v2/facts/{proj_id}/{env_id}/users/{user_id}`, - { - proj_id: scope.project_id, - env_id: targetEnvId, - user_id: user.key, - }, - userData, - undefined, - ); + 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) { + 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( - `Failed to update user ${user.key}: ${updateResult.error}`, + `Update user exception ${user.key}: ${error}`, ); - } else { - stats.success++; } } else { stats.failed++; stats.details?.push( - `Failed to create user ${user.key}: ${createResult.error}`, + `Create user error ${user.key}: ${errorMessage}`, ); } } else { @@ -123,22 +137,20 @@ const useMigrateUsers = () => { } catch (error) { stats.failed++; stats.details?.push( - `Error creating user ${user.key}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `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( - `Error processing user: ${userError instanceof Error ? userError.message : 'Unknown error'}`, + `Processing user error ${userKey}: ${userError instanceof Error ? userError.message : 'Unknown error'}`, ); } } - return stats; } catch (err) { - stats.details?.push( - `User migration error: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); + stats.details?.push(`User migration error: ${err}`); return stats; } }, diff --git a/source/hooks/useDataMigration.tsx b/source/hooks/useDataMigration.tsx index 67940e43..0aec6970 100644 --- a/source/hooks/useDataMigration.tsx +++ b/source/hooks/useDataMigration.tsx @@ -1,20 +1,126 @@ -// hooks/useDataMigration.tsx -import useMigrateResources from './migration/useMigrateResources.js'; +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 { migrateResources } = useMigrateResources(); 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, - migrateRoleAssignments, migrateRoles, migrateResources, + migrateRoleAssignments, }; }; From a9e0c09a93b055450ad9f369c74473d3b5c968f5 Mon Sep 17 00:00:00 2001 From: daveads Date: Fri, 2 May 2025 22:51:56 +0100 Subject: [PATCH 3/3] encapsulated data-migration into copy --- source/commands/env/copy.tsx | 45 +++++- source/components/env/CopyComponent.tsx | 195 +++++++++++++++++++++--- 2 files changed, 216 insertions(+), 24 deletions(-) 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/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}} );