From 378306ab1853a6ecb046d95c8f1b2b3237676173 Mon Sep 17 00:00:00 2001 From: Alberto Di Cagno Date: Tue, 28 Jan 2025 12:37:15 +0100 Subject: [PATCH] feat: script to perform cold start of the poller --- actions/state-manager/index.js | 44 ++++++++ test/refresh-pdps.test.js | 187 +++++++++++++++++++++++++++++++++ tools/README.md | 9 ++ tools/refresh-pdps.js | 125 ++++++++++++++++++++++ 4 files changed, 365 insertions(+) create mode 100644 actions/state-manager/index.js create mode 100644 test/refresh-pdps.test.js create mode 100644 tools/refresh-pdps.js diff --git a/actions/state-manager/index.js b/actions/state-manager/index.js new file mode 100644 index 0000000..2dee0c5 --- /dev/null +++ b/actions/state-manager/index.js @@ -0,0 +1,44 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const stateLib = require('@adobe/aio-lib-state'); + +async function main({ op, key, value }) { + const state = await stateLib.init(); + let result; + + switch (op) { + case 'get': + result = await state.get(key); + break; + case 'put': + result = await state.put(key, value); + break; + case 'delete': + result = await state.delete(key); + break; + case 'stats': + result = await state.stats(); + // eslint-disable-next-line no-fallthrough + case 'list': + default: { + result = []; + for await (const { keys } of state.list()) { + result.push(...keys); + } + } + } + + return { op, key, result }; +} + +exports.main = main; \ No newline at end of file diff --git a/test/refresh-pdps.test.js b/test/refresh-pdps.test.js new file mode 100644 index 0000000..8ab0f29 --- /dev/null +++ b/test/refresh-pdps.test.js @@ -0,0 +1,187 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// Mock modules before requiring the main file +const mockOpenWhiskInstance = { + actions: { + invoke: jest.fn() + }, + rules: { + disable: jest.fn(), + enable: jest.fn() + } +}; + +jest.mock('openwhisk', () => { + return jest.fn(() => mockOpenWhiskInstance); +}); + +jest.mock('dotenv', () => ({ + config: jest.fn() +})); + +jest.mock('commander', () => { + const mockProgram = { + option: jest.fn().mockReturnThis(), + parse: jest.fn().mockReturnThis(), + opts: jest.fn().mockReturnValue({ keys: 'en,fr' }) + }; + return { program: mockProgram }; +}); +// Store original process.env +const originalEnv = process.env; + +describe('Refresh PDP Tool Tests', () => { + let mainModule; + + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv }; + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace'; + process.env.AIO_RUNTIME_AUTH = 'test-auth'; + + // Clear all mocks + jest.clearAllMocks(); + jest.resetModules(); + + // Reset mock functions + mockOpenWhiskInstance.actions.invoke.mockReset(); + mockOpenWhiskInstance.rules.disable.mockReset(); + mockOpenWhiskInstance.rules.enable.mockReset(); + + // Mock exit and console + process.exit = jest.fn(); + console.log = jest.fn(); + console.error = jest.fn(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('Environment Variables', () => { + it('should exit if AIO_RUNTIME_NAMESPACE is missing', () => { + delete process.env.AIO_RUNTIME_NAMESPACE; + require('../tools/refresh-pdps'); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.log).toHaveBeenCalledWith( + 'Missing required environment variables AIO_RUNTIME_AUTH and AIO_RUNTIME_NAMESPACE' + ); + }); + + it('should exit if AIO_RUNTIME_AUTH is missing', () => { + delete process.env.AIO_RUNTIME_AUTH; + require('../tools/refresh-pdps'); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.log).toHaveBeenCalledWith( + 'Missing required environment variables AIO_RUNTIME_AUTH and AIO_RUNTIME_NAMESPACE' + ); + }); + }); + + describe('checkState function', () => { + beforeEach(() => { + mainModule = require('../tools/refresh-pdps'); + }); + + it('should return state value for given locale', async () => { + const expectedState = { value: 'false' }; + mockOpenWhiskInstance.actions.invoke.mockResolvedValueOnce(expectedState); + + const result = await mainModule.checkState('en'); + + expect(mockOpenWhiskInstance.actions.invoke).toHaveBeenCalledWith({ + name: 'state-manager', + params: { key: 'en', op: 'get' }, + blocking: true, + result: true + }); + expect(result).toBe('false'); + }); + + it('should handle errors when checking state', async () => { + mockOpenWhiskInstance.actions.invoke.mockRejectedValueOnce(new Error('API Error')); + await expect(mainModule.checkState('en')).rejects.toThrow('API Error'); + }); + }); + + describe('flushStoreState function', () => { + beforeEach(() => { + mainModule = require('../tools/refresh-pdps'); + }); + + it('should invoke delete operation for given locale', async () => { + await mainModule.flushStoreState('en'); + + expect(mockOpenWhiskInstance.actions.invoke).toHaveBeenCalledWith({ + name: 'state-manager', + params: { key: 'en', op: 'delete' }, + blocking: true, + result: true + }); + }); + + it('should handle errors when flushing state', async () => { + mockOpenWhiskInstance.actions.invoke.mockRejectedValueOnce(new Error('Delete Error')); + await expect(mainModule.flushStoreState('en')).rejects.toThrow('Delete Error'); + }); + }); + + describe('main function', () => { + beforeEach(() => { + mainModule = require('../tools/refresh-pdps'); + mockOpenWhiskInstance.rules.disable.mockResolvedValue({}); + mockOpenWhiskInstance.rules.enable.mockResolvedValue({}); + mockOpenWhiskInstance.actions.invoke + .mockResolvedValueOnce({ value: 'false' }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ value: 'true' }); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should successfully complete the state management cycle', async () => { + const mainPromise = mainModule.main(); + jest.runAllTimers(); + await mainPromise; + + expect(mockOpenWhiskInstance.rules.disable).toHaveBeenCalledWith({ + name: 'poll_every_minute' + }); + expect(mockOpenWhiskInstance.rules.enable).toHaveBeenCalledWith({ + name: 'poll_every_minute' + }); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should timeout if running state never becomes true', async () => { + mockOpenWhiskInstance.actions.invoke.mockResolvedValue({ value: 'false' }); + + const timeout = 1500; // to simulate in the test env + const mainPromise = mainModule.main(timeout); + jest.advanceTimersByTime(1500); + await mainPromise; + + expect(console.error).toHaveBeenCalledWith( + 'Timeout: running state did not become true within 30 minutes.' + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); + }); +}); \ No newline at end of file diff --git a/tools/README.md b/tools/README.md index ffe160f..1b4ef52 100644 --- a/tools/README.md +++ b/tools/README.md @@ -2,6 +2,15 @@ This directory contains utility scripts for various tasks related to the AppBuilder package. Below are the details on how to use each script. +## `refresh-pdps.js` + +This script gracefully pauses execution of the poller, and performs a 'cold start': the poller will then re-preview and re-publish all the products in the catalog. +The operation happens in the background and the script checks that the cold start was successfully triggered, exiting 0. + +### Usage + +`-l, --locales : Comma-separated list of locales (or stores) to delete. For example: en,fr,de` + ## `check-products-count.js` This script checks the product count consistency between the `published-products-index` and the Adobe Commerce store. diff --git a/tools/refresh-pdps.js b/tools/refresh-pdps.js new file mode 100644 index 0000000..5518dc4 --- /dev/null +++ b/tools/refresh-pdps.js @@ -0,0 +1,125 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const openwhisk = require('openwhisk'); +const { program } = require('commander'); +const { exit } = require('process'); + +require('dotenv').config(); + +const { + AIO_RUNTIME_NAMESPACE, + AIO_RUNTIME_AUTH, +} = process.env; + +if (!AIO_RUNTIME_NAMESPACE || !AIO_RUNTIME_AUTH) { + console.log('Missing required environment variables AIO_RUNTIME_AUTH and AIO_RUNTIME_NAMESPACE'); + exit(1); +} + +const [AIO_STATE_MANAGER_ACTION_NAME, AIO_POLLER_RULE_NAME] = ['state-manager', 'poll_every_minute']; + +const ow = openwhisk({ + apihost: 'https://adobeioruntime.net', + api_key: AIO_RUNTIME_AUTH, + namespace: AIO_RUNTIME_NAMESPACE, +}); + +let targetLocales = []; + +program + .requiredOption('-l, --locales ', 'Comma-separated list of locales (or stores) to target. For example: en,fr,de') + .parse(process.argv); + +const options = program.opts(); + +if (!options.keys) { + console.error('No locales provided to delete.'); + exit(1); +} + +targetLocales = options.keys.split(','); + +async function checkState(locale) { + const state = await ow.actions.invoke({ + name: AIO_STATE_MANAGER_ACTION_NAME, + params: { key: locale, op: 'get' }, + blocking: true, + result: true, + }); + return state.value; +} + +async function flushStoreState(locale) { + await ow.actions.invoke({ + name: AIO_STATE_MANAGER_ACTION_NAME, + params: { key: locale, op: 'delete' }, + blocking: true, + result: true, + }); +} + +async function main(timeout = 20 * 60 * 1000) { + + + + try { + // Disable the rule + await ow.rules.disable({ name: AIO_POLLER_RULE_NAME }); + + // Wait until 'running' is not 'true' + let running = await checkState('running'); + while (running === 'true') { + console.log('Waiting for running state to be false...'); + await new Promise(resolve => setTimeout(resolve, 60000)); // Wait 1 minute + running = await checkState('running'); + } + + // Delete specified keys + for (const key of targetLocales) { + await flushStoreState(key); + } + + // Re-enable the rule to trigger the poller cycle start + await ow.rules.enable({ name: AIO_POLLER_RULE_NAME }); + + // Check periodically until 'running' is 'true' + const startTime = Date.now(); + while (true) { + running = await checkState('running'); + if (running === 'true') { + console.log('Running state is true. Exiting...'); + exit(0); + } + if (Date.now() - startTime > timeout) { + console.error('Timeout: running state did not become true within 30 minutes.'); + exit(1); + } + await new Promise(resolve => setTimeout(resolve, 60000)); // Wait 1 minute + } + } catch (error) { + console.error('Error:', error); + exit(1); + } +} + +module.exports = { + checkState, + flushStoreState, + main, + targetLocales, +}; + +// Only call main if this file is being run directly +if (require.main === module) { + main(); +} \ No newline at end of file