diff --git a/package.json b/package.json index 15dc0bd5..4352b05a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", "@adobe/aio-lib-core-networking": "^5", + "@adobe/aio-lib-db": "^0.1.0-beta.4", "@adobe/aio-lib-env": "^3", "@adobe/aio-lib-ims": "^7", "@adobe/aio-lib-runtime": "^7.1.3", diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index 03fa59bb..b5eed90c 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -24,6 +24,8 @@ const { getFilesCountWithExtension } = require('../../lib/app-helper') const rtLib = require('@adobe/aio-lib-runtime') +const dbLib = require('@adobe/aio-lib-db') +const { DB_STATUS } = require('../../lib/defaults') const LogForwarding = require('../../lib/log-forwarding') const { sendAppAssetsDeployedAuditLog, sendAppDeployAuditLog } = require('../../lib/audit-logger') const { setRuntimeApiHostAndAuthHandler, getAccessToken } = require('../../lib/auth-helper') @@ -204,6 +206,11 @@ class Deploy extends BuildCommand { this.error(err) } + // provision database if configured + if (config.manifest?.full?.database?.['auto-provision'] === true) { + await this.provisionDatabase(config, spinner, flags) + } + if (flags.actions) { if (config.app.hasBackend) { let filterEntities @@ -300,6 +307,74 @@ class Deploy extends BuildCommand { } } + async provisionDatabase (config, spinner, flags) { + const { namespace, auth } = config.ow || {} + if (!(namespace && auth)) { + throw new Error('Database deployment requires OW auth configuration.') + } + const region = config.manifest?.full?.database?.region + const regionMess = region ? `'${region}'` : 'default' + + const progress = ({ next = undefined, status = undefined, verboseOnly = false }, statusMethod = spinner.info) => { + if (flags.verbose) { + const method = statusMethod.bind(spinner) + method(status) + spinner.start(next) + } else if (next && !verboseOnly) { + spinner.text = next + } + } + + let provRes + try { + spinner.start(`Deploying database in the ${regionMess} region...`) + + const db = await dbLib.init({ ow: { namespace, auth }, region }) + progress({ next: 'Checking existing database deployment status...', verboseOnly: true }) + + let prevStatus + let statusRegion + const next = `Submitting database provisioning request in the ${regionMess} region...` + try { + const statusRes = await db.provisionStatus() + prevStatus = statusRes.status.toUpperCase() + statusRegion = statusRes.region + const regionMessage = statusRegion ? ` in region '${statusRegion}'` : '' + progress({ status: chalk.dim(`Existing database provisioning status: ${prevStatus}${regionMessage}`), next }) + } catch (err) { + progress({ status: chalk.red(`Database status check failed: ${err.message}`), next }, spinner.warn) + prevStatus = null + } + + if (prevStatus === DB_STATUS.PROVISIONED) { + spinner.succeed(chalk.green(`Database is deployed and ready for use in the '${statusRegion}' region`)) + return + } else if (prevStatus === DB_STATUS.REQUESTED || prevStatus === DB_STATUS.PROCESSING) { + spinner.succeed(chalk.green(`Database provisioning request has already been submitted in the '${statusRegion}' region and is pending`)) + return + } + + provRes = await db.provisionRequest() + progress({ status: chalk.dim(`Database provisioning result:\n${JSON.stringify(provRes, null, 2)}`) }) + } catch (error) { + spinner.fail(chalk.red('Database deployment failed')) + throw error + } + + const resultStatus = provRes?.status?.toUpperCase() || DB_STATUS.UNKNOWN + if (resultStatus === DB_STATUS.PROVISIONED) { + spinner.succeed(chalk.green(`Database is deployed and ready for use in the '${provRes.region}' region`)) + } else if (resultStatus === DB_STATUS.REQUESTED || resultStatus === DB_STATUS.PROCESSING) { + spinner.succeed(chalk.green(`Database provisioning request submitted in the '${provRes.region}' region, database deployment is now pending`)) + } else if (resultStatus === DB_STATUS.FAILED || resultStatus === DB_STATUS.REJECTED) { + const message = `Database provisioning request failed with status '${resultStatus}'` + spinner.fail(chalk.red(message)) + throw new Error(`${message}: ${provRes.message || 'Unknown error'}`) + } else { + spinner.warn(chalk.yellow(`Database provisioning request returned unexpected status '${resultStatus}', an update to the aio cli tool may be necessary.`)) + } + } + async publishExtensionPoints (deployConfigs, aioConfig, force) { const libConsoleCLI = await this.getLibConsoleCLI() diff --git a/src/lib/defaults.js b/src/lib/defaults.js index c24d5440..c68a54e4 100644 --- a/src/lib/defaults.js +++ b/src/lib/defaults.js @@ -40,5 +40,16 @@ module.exports = { EXTENSIONS_CONFIG_KEY: 'extensions', // Adding tracking file constants LAST_BUILT_ACTIONS_FILENAME: 'last-built-actions.json', - LAST_DEPLOYED_ACTIONS_FILENAME: 'last-deployed-actions.json' + LAST_DEPLOYED_ACTIONS_FILENAME: 'last-deployed-actions.json', + // Database constants + DB_STATUS: { + PROVISIONED: 'PROVISIONED', + REQUESTED: 'REQUESTED', + PROCESSING: 'PROCESSING', + FAILED: 'FAILED', + REJECTED: 'REJECTED', + NOT_PROVISIONED: 'NOT_PROVISIONED', + DELETED: 'DELETED', + UNKNOWN: 'UNKNOWN' + } } diff --git a/test/commands/app/deploy.test.js b/test/commands/app/deploy.test.js index 054b5836..d7942ef4 100644 --- a/test/commands/app/deploy.test.js +++ b/test/commands/app/deploy.test.js @@ -18,6 +18,7 @@ const helpersActual = jest.requireActual('../../../src/lib/app-helper.js') const authHelpersActual = jest.requireActual('../../../src/lib/auth-helper') const open = require('open') +const ora = require('ora') const mockBundleFunc = jest.fn() jest.mock('../../../src/lib/app-helper.js') @@ -32,6 +33,9 @@ const authHelper = require('../../../src/lib/auth-helper') const mockWebLib = require('@adobe/aio-lib-web') const mockRuntimeLib = require('@adobe/aio-lib-runtime') +jest.mock('@adobe/aio-lib-db') +const mockDbLib = require('@adobe/aio-lib-db') + jest.mock('@adobe/aio-lib-core-config') const mockConfig = require('@adobe/aio-lib-core-config') @@ -55,6 +59,7 @@ jest.mock('../../../src/lib/log-forwarding', () => { } }) const LogForwarding = require('../../../src/lib/log-forwarding') +const { DB_STATUS } = require('../../../src/lib/defaults') const createWebExportAnnotation = (value) => ({ annotations: { 'web-export': value } @@ -204,6 +209,7 @@ beforeEach(() => { command = new TheCommand([]) command.error = jest.fn() command.log = jest.fn() + command.warn = jest.fn() command.appConfig = cloneDeep(mockConfigData) command.appConfig.actions = { dist: 'actions' } command.appConfig.web.distProd = 'dist' @@ -1630,3 +1636,331 @@ describe('run', () => { expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) }) }) + +describe('database provisioning', () => { + let mockDb + beforeEach(() => { + mockDb = { + provisionStatus: jest.fn(), + provisionRequest: jest.fn() + } + mockDbLib.init.mockResolvedValue(mockDb) + }) + + // Helper functions for unit tests + const createDatabaseConfig = (region = null, provision = true) => ({ + ow: { namespace: 'test_ns', auth: 'user:pass' }, + manifest: { + full: { + database: { + 'auto-provision': provision, + ...(region && { region }) + } + } + } + }) + + const runProvisionTest = async ( + config, + flags, + spinner, + mockResult = { status: DB_STATUS.PROVISIONED, region: 'amer' }, + mockStatus = { status: DB_STATUS.NOT_PROVISIONED } + ) => { + if (mockStatus instanceof Error) { + mockDb.provisionStatus.mockRejectedValueOnce(mockStatus) + } else { + mockDb.provisionStatus.mockResolvedValueOnce(mockStatus) + } + + if (mockResult instanceof Error) { + mockDb.provisionRequest.mockRejectedValueOnce(mockResult) + } else { + mockDb.provisionRequest.mockResolvedValueOnce(mockResult) + } + + await command.provisionDatabase(config, spinner, flags).catch(e => { throw e }) + return spinner + } + + test('should provision database when auto-provision is true', async () => { + mockDb.provisionStatus.mockResolvedValue({ status: DB_STATUS.NOT_PROVISIONED }) + mockDb.provisionRequest.mockResolvedValue({ status: DB_STATUS.PROVISIONED, region: 'amer' }) + const appConfigWithDb = createAppConfig({ ...command.appConfig, ...createDatabaseConfig() }) + + command.getAppExtConfigs.mockResolvedValueOnce(appConfigWithDb) + + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + + const expectedInitConfig = { ow: { namespace: 'test_ns', auth: 'user:pass' } } + expect(mockDbLib.init).toHaveBeenCalledWith(expect.objectContaining(expectedInitConfig)) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + + expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) + expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) + expect(command.log).toHaveBeenCalledWith( + expect.stringContaining('Successful deployment 🏄') + ) + }) + + test('should not provision database when auto-provision is false', async () => { + mockDb.provisionStatus.mockResolvedValue({ status: DB_STATUS.NOT_PROVISIONED }) + mockDb.provisionRequest.mockResolvedValue({ status: DB_STATUS.PROVISIONED, region: 'emea' }) + + const appConfigWithoutDb = createAppConfig({ ...command.appConfig, ...createDatabaseConfig(null, false) }) + + command.getAppExtConfigs.mockResolvedValueOnce(appConfigWithoutDb) + + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockDbLib.init).not.toHaveBeenCalled() + expect(mockDb.provisionStatus).not.toHaveBeenCalled() + expect(mockDb.provisionRequest).not.toHaveBeenCalled() + expect(mockRuntimeLib.deployActions).toHaveBeenCalledTimes(1) + expect(mockWebLib.deployWeb).toHaveBeenCalledTimes(1) + expect(command.log).toHaveBeenCalledWith( + expect.stringContaining('Successful deployment 🏄') + ) + }) + + // tests for provisionDatabase method behavior + test('should run provision command correctly with region', async () => { + const config = createDatabaseConfig('amer') + const flags = { verbose: false } + const spinner = ora() + + await runProvisionTest(config, flags, spinner) + + expect(spinner.start).toHaveBeenCalledWith(expect.stringContaining('Deploying database in the \'amer\' region')) + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Database is deployed and ready for use')) + + const expectedInitConfig = { + ow: { namespace: 'test_ns', auth: 'user:pass' }, + region: 'amer' + } + expect(mockDbLib.init).toHaveBeenCalledWith(expect.objectContaining(expectedInitConfig)) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + }) + + test('should run provision command correctly without region', async () => { + const config = createDatabaseConfig() + const flags = { verbose: false } + const spinner = ora() + + await runProvisionTest(config, flags, spinner) + + expect(spinner.start).toHaveBeenCalledWith(expect.stringContaining('Deploying database in the default region')) + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Database is deployed and ready for use')) + + const expectedInitConfig = { ow: { namespace: 'test_ns', auth: 'user:pass' } } + expect(mockDbLib.init).toHaveBeenCalledWith(expect.objectContaining(expectedInitConfig)) + expect(mockDbLib.init).not.toHaveBeenCalledWith(expect.objectContaining({ region: expect.any(String) })) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + }) + + test('should show verbose output with region', async () => { + const config = createDatabaseConfig('apac') + const flags = { verbose: true } + const spinner = ora() + + await runProvisionTest(config, flags, spinner, { status: DB_STATUS.PROVISIONED, region: 'apac' }, { status: DB_STATUS.DELETED }) + + // Existing database status + expect(spinner.start).toHaveBeenCalledWith(expect.stringContaining('Checking existing database deployment status...')) + expect(spinner.info).toHaveBeenCalledWith(expect.stringContaining('status: DELETED')) + // Provision request + expect(spinner.start).toHaveBeenCalledWith(expect.stringContaining('in the \'apac\' region...')) + expect(spinner.info).toHaveBeenCalledWith(expect.stringContaining('Database provisioning result:')) + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Database is deployed and ready for use')) + }) + + test('should show verbose output without region', async () => { + const config = createDatabaseConfig() + const flags = { verbose: true } + const spinner = ora() + + await runProvisionTest(config, flags, spinner, { status: DB_STATUS.PROVISIONED, region: 'emea' }) + + // Existing database status + expect(spinner.start).toHaveBeenCalledWith(expect.stringContaining('Checking existing database deployment status...')) + expect(spinner.info).toHaveBeenCalledWith(expect.stringContaining('status: NOT_PROVISIONED')) + // Provision request + expect(spinner.start).toHaveBeenCalledWith(expect.stringContaining('in the default region...')) + expect(spinner.info).toHaveBeenCalledWith(expect.stringContaining('Database provisioning result:')) + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Database is deployed and ready for use')) + }) + + test('should handle provision command failure', async () => { + const config = createDatabaseConfig('amer') + const flags = { verbose: false } + const error = new Error('Database provision failed') + const spinner = ora() + + await expect(runProvisionTest(config, flags, spinner, error)) + .rejects.toThrow('Database provision failed') + expect(spinner.fail).toHaveBeenCalled() + }) + + test('should not provision if database is already provisioned', async () => { + const config = createDatabaseConfig('apac') + const spinner = ora() + + await runProvisionTest(config, {}, spinner, null, { status: DB_STATUS.PROVISIONED, region: 'apac' }) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).not.toHaveBeenCalled() + + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('Database is deployed and ready for use')) + }) + + test('should not provision database if request is pending', async () => { + const config = createDatabaseConfig('apac') + const spinner = ora() + + await runProvisionTest(config, {}, spinner, null, { status: DB_STATUS.REQUESTED, region: 'apac' }) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).not.toHaveBeenCalled() + + expect(spinner.succeed).toHaveBeenCalledWith( + expect.stringContaining('already been submitted') + ) + }) + + test('should try to provision if previous request failed', async () => { + const config = createDatabaseConfig('apac') + + await runProvisionTest(config, { }, ora(), undefined, { status: DB_STATUS.FAILED, region: 'apac' }) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + }) + + test('should try to provision if database status check fails with verbose flag', async () => { + const config = createDatabaseConfig('apac') + const spinner = ora() + + await runProvisionTest(config, { verbose: true }, spinner, undefined, new Error('Status check failure')) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.warn).toHaveBeenCalledWith(expect.stringContaining('status check failed')) + }) + + test('should try to provision if database status check fails without verbose flag', async () => { + const config = createDatabaseConfig('apac') + const spinner = ora() + + await runProvisionTest(config, { }, spinner, undefined, new Error('Status check failure')) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.warn).not.toHaveBeenCalledWith(expect.stringContaining('status check failed')) + }) + + test('should report success when provision request gets submitted and responds as pending', async () => { + const config = createDatabaseConfig() + const spinner = ora() + + await runProvisionTest(config, {}, spinner, { status: DB_STATUS.PROCESSING, region: 'apac' }) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('request submitted')) + }) + + test('should report warning when provision request reports unrecognized status', async () => { + const config = createDatabaseConfig() + const spinner = ora() + + await runProvisionTest(config, {}, spinner, { status: 'OTHER_STATUS' }) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.warn).toHaveBeenCalledWith(expect.stringContaining('unexpected status')) + }) + + test('should report warning when provision request responds without status', async () => { + const config = createDatabaseConfig() + const spinner = ora() + + await runProvisionTest(config, {}, spinner, {}) + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.warn).toHaveBeenCalledWith(expect.stringContaining('unexpected status')) + }) + + test('should throw error if provision request returns failure status with known message', async () => { + const config = createDatabaseConfig() + const spinner = ora() + + await expect(runProvisionTest(config, {}, spinner, { status: DB_STATUS.FAILED, message: 'Could not be provisioned' })) + .rejects.toThrow('Could not be provisioned') + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.fail).toHaveBeenCalled() + }) + + test('should throw error if provision request returns failure status with unknown message', async () => { + const config = createDatabaseConfig() + const spinner = ora() + + await expect(runProvisionTest(config, {}, spinner, { status: DB_STATUS.REJECTED })) + .rejects.toThrow('Unknown error') + + expect(mockDbLib.init).toHaveBeenCalledTimes(1) + expect(mockDb.provisionStatus).toHaveBeenCalledTimes(1) + expect(mockDb.provisionRequest).toHaveBeenCalledTimes(1) + expect(spinner.fail).toHaveBeenCalled() + }) + + test('should throw error if OW auth is missing in config', async () => { + const config = createDatabaseConfig() + config.ow = { namespace: 'test_ns' } // missing auth + + await expect(runProvisionTest(config, {}, ora())).rejects.toThrow() + + expect(mockDbLib.init).not.toHaveBeenCalled() + expect(mockDb.provisionStatus).not.toHaveBeenCalled() + expect(mockDb.provisionRequest).not.toHaveBeenCalled() + }) + + test('should throw error if OW namespace is missing in config', async () => { + const config = createDatabaseConfig() + config.ow = { auth: 'user:pass' } // missing namespace + + await expect(runProvisionTest(config, {}, ora())).rejects.toThrow() + + expect(mockDbLib.init).not.toHaveBeenCalled() + expect(mockDb.provisionStatus).not.toHaveBeenCalled() + expect(mockDb.provisionRequest).not.toHaveBeenCalled() + }) + + test('should throw error if OW config is missing entirely', async () => { + const config = createDatabaseConfig() + delete config.ow + + await expect(runProvisionTest(config, {}, ora())).rejects.toThrow() + + expect(mockDbLib.init).not.toHaveBeenCalled() + expect(mockDb.provisionStatus).not.toHaveBeenCalled() + expect(mockDb.provisionRequest).not.toHaveBeenCalled() + }) +})