diff --git a/lib/cmds/migrate.js b/lib/cmds/migrate.js new file mode 100644 index 000000000..26a22333e --- /dev/null +++ b/lib/cmds/migrate.js @@ -0,0 +1,5 @@ +exports.command = 'migrate '; +exports.desc = 'Migration utilities'; +exports.builder = function (yargs) { + return yargs.commandDir('migrate_cmds'); +}; diff --git a/lib/cmds/migrate_cmds/datacenter.js b/lib/cmds/migrate_cmds/datacenter.js new file mode 100644 index 000000000..0354bb0e4 --- /dev/null +++ b/lib/cmds/migrate_cmds/datacenter.js @@ -0,0 +1,194 @@ +const path = require('path') +const fs = require('fs') +const os = require('os') +const runContentfulExport = require('contentful-export') +const runContentfulImport = require('contentful-import') +const { handleAsyncError: handle } = require('../../utils/async') +const { version } = require('../../../package.json') +const logging = require('../../utils/log') +const { getHeadersFromOption } = require('../../utils/headers') +const emojic = require('emojic') + +const HOST_MAP = { + eu: 'api.eu.contentful.com', + na: 'api.contentful.com' +} + +const ensureDirExists = dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } +} + +const migrateDataCenter = async argv => { + const { + source, + target, + sourceSpaceId, + targetSpaceId, + sourceToken, + targetToken, + environmentId, + useVerboseRenderer + } = argv + + const exportDir = path.join(process.cwd(), 'tmp-export') + ensureDirExists(exportDir) + + if (argv.includeTaxonomies) { + if (!argv.sourceOrgId || !argv.targetOrgId) { + throw new Error( + '--source-org-id and --target-org-id are required when --include-taxonomies is set' + ) + } + + const taxonomyFile = path.join( + exportDir, + `taxonomy-${argv.sourceOrgId}-${Date.now()}.json` + ) + + console.log( + `Exporting taxonomies from source organization ${argv.sourceOrgId}...` + ) + + const { + organizationExport + } = require('../../cmds/organization_cmds/export') + + await organizationExport({ + context: { managementToken: argv.sourceToken }, + organizationId: argv.sourceOrgId, + header: argv.header, + outputFile: taxonomyFile, + saveFile: true, + host: HOST_MAP[target] + }) + + logging.success(`${emojic.whiteCheckMark} Taxonomy export complete`) + + console.log( + `${emojic.inboxTray} Importing taxonomies into target organization ${argv.targetOrgId}...` + ) + + const { + organizationImport + } = require('../../cmds/organization_cmds/import') + + await organizationImport({ + context: { managementToken: argv.targetToken }, + organizationId: argv.targetOrgId, + header: argv.header, + contentFile: taxonomyFile, + silent: !argv.useVerboseRenderer, + host: HOST_MAP[target] + }) + + logging.success(`${emojic.whiteCheckMark} Taxonomy import complete`) + } + + const contentFile = path.join( + exportDir, + `contentful-export-${sourceSpaceId}-${environmentId}-${Date.now()}.json` + ) + + console.log(`🚚 Exporting contentful data from [${source}]...`) + await runContentfulExport({ + spaceId: sourceSpaceId, + environmentId, + managementToken: sourceToken, + host: HOST_MAP[source], + contentFile, + saveFile: true, + useVerboseRenderer, + managementApplication: `contentful.cli/${version}`, + managementFeature: 'migrate-datacenter', + headers: getHeadersFromOption(argv.header) + }) + + logging.success(`${emojic.whiteCheckMark} Export complete`) + + console.log(`${emojic.inboxTray} Importing content into [${target}]...`) + await runContentfulImport({ + spaceId: targetSpaceId, + environmentId, + managementToken: targetToken, + host: HOST_MAP[target], + contentFile, + useVerboseRenderer, + managementApplication: `contentful.cli/${version}`, + managementFeature: 'migrate-datacenter', + headers: getHeadersFromOption(argv.header) + }) + + logging.success(`${emojic.tada} Migration complete!`) +} + +module.exports.command = 'datacenter' +module.exports.desc = 'Migrate a space between Contentful data centers' +module.exports.builder = yargs => + yargs + .option('source-dc', { + alias: 'source', + describe: 'Source data center (na or eu)', + choices: ['na', 'eu'], + demandOption: true + }) + .option('target-dc', { + alias: 'target', + describe: 'Target data center (na or eu)', + choices: ['na', 'eu'], + demandOption: true + }) + .option('source-space-id', { + describe: 'Source space ID', + type: 'string', + demandOption: true + }) + .option('target-space-id', { + describe: 'Target space ID', + type: 'string', + demandOption: true + }) + .option('environment-id', { + describe: 'Environment ID (e.g. master)', + type: 'string', + default: 'master' + }) + .option('source-token', { + describe: 'Source CMA token', + type: 'string', + demandOption: true + }) + .option('target-token', { + describe: 'Target CMA token', + type: 'string', + demandOption: true + }) + .option('use-verbose-renderer', { + describe: 'Display progress in new lines instead of spinner', + type: 'boolean', + default: false + }) + .option('include-taxonomies', { + describe: + 'Migrate taxonomies (Concepts & Concept Schemes) before importing content', + type: 'boolean', + default: false + }) + .option('source-org-id', { + describe: 'Organization ID where taxonomies should be exported from', + type: 'string' + }) + .option('target-org-id', { + describe: 'Organization ID where taxonomies should be imported to', + type: 'string' + }) + .option('skip-content-publishing', { + describe: + 'Skips content publishing. Creates content but does not publish it', + type: 'boolean', + default: false + }) + +module.exports.handler = handle(migrateDataCenter) +module.exports.migrateDataCenter = migrateDataCenter diff --git a/lib/cmds/organization_cmds/import.ts b/lib/cmds/organization_cmds/import.ts index b62d822a3..37d9e3412 100644 --- a/lib/cmds/organization_cmds/import.ts +++ b/lib/cmds/organization_cmds/import.ts @@ -66,6 +66,7 @@ export interface OrgImportParams { contentFile: string silent?: boolean errorLogFile?: string + host?: string } export interface OrgImportContext { @@ -85,15 +86,23 @@ interface ErrorMessage { } async function importCommand(params: OrgImportParams) { - const { context, header, organizationId, contentFile, silent, errorLogFile } = + const { context, header, organizationId, contentFile, silent, errorLogFile, host } = params const { managementToken } = context + console.log( + '👉 CMA Host being used for taxonomy import:', + host || 'api.contentful.com' + ) + console.log('👉 Token prefix:', managementToken) + console.log('👉 Organization ID:', organizationId) + const cmaClient = await createPlainClient({ accessToken: managementToken, feature: 'org-import', headers: getHeadersFromOption(header), - logHandler: noop + logHandler: noop, + host }) const importContext: OrgImportContext = { diff --git a/lib/utils/contentful-clients.js b/lib/utils/contentful-clients.js index 2f6994c64..fa1c7fc81 100644 --- a/lib/utils/contentful-clients.js +++ b/lib/utils/contentful-clients.js @@ -21,10 +21,8 @@ async function createManagementClient(params) { } async function createPlainClient(params, defaults = {}) { - params.application = `contentful.cli/${version}` - const context = await getContext() - const { rawProxy, proxy, host, insecure } = context + const { rawProxy, proxy, host: contextHost, insecure } = context const proxyConfig = {} if (!rawProxy) { @@ -34,8 +32,19 @@ async function createPlainClient(params, defaults = {}) { proxyConfig.proxy = proxy } + // Added this to ensure that the host is set correctly, currently we rely on a json file that sets it, this allows us to migrate and pass a flag if set for the migrate option + const effectiveHost = params.host || contextHost || 'api.contentful.com' + + console.log('🌐 Final CMA host:', effectiveHost) + return createClient( - { ...params, ...proxyConfig, host, insecure }, + { + ...params, + ...proxyConfig, + host: effectiveHost, + insecure, + application: `contentful.cli/${version}` + }, { type: 'plain', defaults } ) } diff --git a/test/unit/cmds/migrate_cmds/datacenter.test.js b/test/unit/cmds/migrate_cmds/datacenter.test.js new file mode 100644 index 000000000..81eabb7f2 --- /dev/null +++ b/test/unit/cmds/migrate_cmds/datacenter.test.js @@ -0,0 +1,145 @@ +const fs = require('fs') +const path = require('path') +const runContentfulExport = require('contentful-export') +const runContentfulImport = require('contentful-import') +const { version } = require('../../../../package.json') + +jest.mock('fs') +jest.mock('path') +jest.mock('contentful-export') +jest.mock('contentful-import') +jest.mock('../../../../lib/utils/log') +jest.mock('../../../../lib/utils/headers', () => ({ + getHeadersFromOption: jest.fn(() => ({})) +})) +jest.mock('../../../../lib/cmds/organization_cmds/export', () => ({ + organizationExport: jest.fn() +})) +jest.mock('../../../../lib/cmds/organization_cmds/import', () => ({ + organizationImport: jest.fn() +})) +jest.mock('emojic', () => ({ + whiteCheckMark: '✅', + inboxTray: '📥', + tada: '🎉' +})) + +const { + organizationExport +} = require('../../../../lib/cmds/organization_cmds/export') +const { + organizationImport +} = require('../../../../lib/cmds/organization_cmds/import') +const { migrateDataCenter } = require('../../../../lib/cmds/migrate_cmds/datacenter') + +describe('migrateDataCenter', () => { + beforeEach(() => { + jest.clearAllMocks() + fs.existsSync.mockReturnValue(false) + fs.mkdirSync.mockImplementation(() => {}) + path.join.mockImplementation((...parts) => parts.join('/')) + runContentfulExport.mockResolvedValue({}) + runContentfulImport.mockResolvedValue({}) + }) + + test('runs export and import without taxonomies', async () => { + const argv = { + source: 'eu', + target: 'na', + sourceSpaceId: 'sourceSpaceId', + targetSpaceId: 'targetSpaceId', + sourceToken: 'sourceToken', + targetToken: 'targetToken', + environmentId: 'master', + useVerboseRenderer: false, + includeTaxonomies: false, + header: [] + } + + await migrateDataCenter(argv) + + expect(runContentfulExport).toHaveBeenCalledWith( + expect.objectContaining({ + spaceId: 'sourceSpaceId', + environmentId: 'master', + managementToken: 'sourceToken', + host: 'api.eu.contentful.com', + useVerboseRenderer: false, + managementApplication: `contentful.cli/${version}`, + managementFeature: 'migrate-datacenter' + }) + ) + + expect(runContentfulImport).toHaveBeenCalledWith( + expect.objectContaining({ + spaceId: 'targetSpaceId', + environmentId: 'master', + managementToken: 'targetToken', + host: 'api.contentful.com', + useVerboseRenderer: false, + managementApplication: `contentful.cli/${version}`, + managementFeature: 'migrate-datacenter' + }) + ) + + expect(organizationExport).not.toHaveBeenCalled() + expect(organizationImport).not.toHaveBeenCalled() + }) + + test('runs taxonomy export/import when includeTaxonomies is true', async () => { + const argv = { + source: 'na', + target: 'eu', + sourceSpaceId: 'sourceSpaceId', + targetSpaceId: 'targetSpaceId', + sourceToken: 'sourceToken', + targetToken: 'targetToken', + environmentId: 'master', + useVerboseRenderer: true, + includeTaxonomies: true, + sourceOrgId: 'srcOrg', + targetOrgId: 'tgtOrg', + header: [] + } + + await migrateDataCenter(argv) + + expect(organizationExport).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'srcOrg', + context: { managementToken: 'sourceToken' }, + host: 'api.eu.contentful.com' + }) + ) + + expect(organizationImport).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'tgtOrg', + context: { managementToken: 'targetToken' }, + host: 'api.eu.contentful.com' + }) + ) + + expect(runContentfulExport).toHaveBeenCalled() + expect(runContentfulImport).toHaveBeenCalled() + }) + + test('throws if includeTaxonomies is true but org IDs are missing', async () => { + const argv = { + source: 'na', + target: 'eu', + sourceSpaceId: 'sourceSpaceId', + targetSpaceId: 'targetSpaceId', + sourceToken: 'sourceToken', + targetToken: 'targetToken', + environmentId: 'master', + useVerboseRenderer: false, + includeTaxonomies: true, + header: [] + } + + await expect(migrateDataCenter(argv)).rejects.toThrow( + '--source-org-id and --target-org-id are required when --include-taxonomies is set' + ) + }) +})