diff --git a/lib/cloud-api/helpers.js b/lib/cloud-api/helpers.js index 22f7954c..62721f47 100644 --- a/lib/cloud-api/helpers.js +++ b/lib/cloud-api/helpers.js @@ -90,6 +90,59 @@ async function checkAndEnableApi( } } +/** + * Ensures that the specified Google Cloud APIs are enabled for the given project. + * If an API is not enabled, it attempts to enable it. Retries any failure once after 1s. + * Throws an error if an API cannot be enabled. + * + * @async + * @param {object} context - The context object containing clients and other parameters. + * @param {string} projectId - The Google Cloud project ID. + * @param {string[]} apis - An array of API identifiers to check and enable (e.g., 'run.googleapis.com'). + * @param {function(string, string=): void} progressCallback - A function to call with progress updates. + * The first argument is the message, the optional second argument is the type ('error', 'warning', 'info'). + * @throws {Error} If an API fails to enable or if there's an issue checking its status. + * @returns {Promise} A promise that resolves when all specified APIs are enabled. + */ +async function enableApisWithRetry(context, projectId, apis, progressCallback) { + if (!apis || !Array.isArray(apis)) { + return; + } + for (const api of apis) { + const serviceName = `projects/${projectId}/services/${api}`; + try { + await checkAndEnableApi( + context.serviceUsageClient, + serviceName, + api, + progressCallback + ); + } catch (error) { + // First attempt failed, log a warning and retry once after a delay. + const warnMsg = `Failed to check/enable ${api}, retrying in 1s...`; + console.warn(warnMsg); + if (progressCallback) progressCallback({ level: 'warn', data: warnMsg }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + await checkAndEnableApi( + context.serviceUsageClient, + serviceName, + api, + progressCallback + ); + } catch (retryError) { + // If the retry also fails, throw an error. + const errorMessage = `Failed to ensure API [${api}] is enabled after retry. Please check manually.`; + console.error(errorMessage, retryError); + if (progressCallback) + progressCallback({ level: 'error', data: errorMessage }); + throw new Error(errorMessage); + } + } + } +} + /** * Ensures that the specified Google Cloud APIs are enabled for the given project. * If an API is not enabled, it attempts to enable it. Retries any failure once after 1s. @@ -110,6 +163,18 @@ export async function ensureApisEnabled( apis, progressCallback ) { + const prerequisiteApis = [ + 'serviceusage.googleapis.com', + 'cloudbilling.googleapis.com', + ]; + + await enableApisWithRetry( + context, + projectId, + prerequisiteApis, + progressCallback + ); + if (!(await isBillingEnabled(projectId))) { // Billing is disabled, try to fix it. const accounts = await listBillingAccounts(); @@ -158,39 +223,7 @@ export async function ensureApisEnabled( console.log(message); if (progressCallback) progressCallback({ level: 'info', data: message }); - for (const api of apis) { - const serviceName = `projects/${projectId}/services/${api}`; - try { - await checkAndEnableApi( - context.serviceUsageClient, - serviceName, - api, - progressCallback - ); - } catch (error) { - // First attempt failed, log a warning and retry once after a delay. - const warnMsg = `Failed to check/enable ${api}, retrying in 1s...`; - console.warn(warnMsg); - if (progressCallback) progressCallback({ level: 'warn', data: warnMsg }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - try { - await checkAndEnableApi( - context.serviceUsageClient, - serviceName, - api, - progressCallback - ); - } catch (retryError) { - // If the retry also fails, throw an error. - const errorMessage = `Failed to ensure API [${api}] is enabled after retry. Please check manually.`; - console.error(errorMessage, retryError); - if (progressCallback) - progressCallback({ level: 'error', data: errorMessage }); - throw new Error(errorMessage); - } - } - } + await enableApisWithRetry(context, projectId, apis, progressCallback); const successMsg = 'All required APIs are enabled.'; console.log(successMsg); if (progressCallback) progressCallback({ level: 'info', data: successMsg }); diff --git a/lib/deployment/deployer.js b/lib/deployment/deployer.js index 03d9054e..a2b7a496 100644 --- a/lib/deployment/deployer.js +++ b/lib/deployment/deployer.js @@ -32,18 +32,20 @@ const IMAGE_TAG = 'latest'; // APIs required for deploying from source code. const REQUIRED_APIS_FOR_SOURCE_DEPLOY = [ + 'serviceusage.googleapis.com', + 'cloudbilling.googleapis.com', 'iam.googleapis.com', 'storage.googleapis.com', 'cloudbuild.googleapis.com', 'artifactregistry.googleapis.com', 'run.googleapis.com', - 'serviceusage.googleapis.com', ]; // APIs required for deploying a container image. const REQUIRED_APIS_FOR_IMAGE_DEPLOY = [ - 'run.googleapis.com', 'serviceusage.googleapis.com', + 'cloudbilling.googleapis.com', + 'run.googleapis.com', ]; /** diff --git a/test/local/helpers.test.js b/test/local/helpers.test.js index 3601d961..0a2b8f5b 100644 --- a/test/local/helpers.test.js +++ b/test/local/helpers.test.js @@ -45,10 +45,104 @@ describe('ensureApisEnabled', () => { afterEach(() => { mock.restoreAll(); + delete process.env.SKIP_API_DELAY; + }); + + beforeEach(() => { + process.env.SKIP_API_DELAY = 'true'; + }); + + describe('API Pre-checks', () => { + it('should enable serviceusage, cloudbilling, then check billing and enable api list', async () => { + billingMocks.isBillingEnabled.mock.mockImplementation(() => + Promise.resolve(true) + ); + // Return DISABLED for all APIs + mockServiceUsageClient.getService.mock.mockImplementation(() => + Promise.resolve([{ state: 'DISABLED' }]) + ); + + await ensureApisEnabled( + { serviceUsageClient: mockServiceUsageClient }, + projectId, + apis, + mockProgressCallback + ); + + // getService should be called for serviceusage, cloudbilling, and the test api + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 3); + assert.match( + mockServiceUsageClient.getService.mock.calls[0].arguments[0].name, + /serviceusage\.googleapis\.com/ + ); + assert.match( + mockServiceUsageClient.getService.mock.calls[1].arguments[0].name, + /cloudbilling\.googleapis\.com/ + ); + assert.match( + mockServiceUsageClient.getService.mock.calls[2].arguments[0].name, + /test-api\.googleapis\.com/ + ); + + // enableService should be called for serviceusage, cloudbilling, and the test api + assert.strictEqual( + mockServiceUsageClient.enableService.mock.callCount(), + 3 + ); + assert.match( + mockServiceUsageClient.enableService.mock.calls[0].arguments[0].name, + /serviceusage\.googleapis\.com/ + ); + assert.match( + mockServiceUsageClient.enableService.mock.calls[1].arguments[0].name, + /cloudbilling\.googleapis\.com/ + ); + assert.match( + mockServiceUsageClient.enableService.mock.calls[2].arguments[0].name, + /test-api\.googleapis\.com/ + ); + + // isBillingEnabled should be called once + assert.strictEqual(billingMocks.isBillingEnabled.mock.callCount(), 1); + }); + + it('should only check billing and api list if prereq APIs enabled', async () => { + billingMocks.isBillingEnabled.mock.mockImplementation(() => + Promise.resolve(true) + ); + // Only disable the test API + mockServiceUsageClient.getService.mock.mockImplementation(({ name }) => { + if (name.includes('test-api')) { + return Promise.resolve([{ state: 'DISABLED' }]); + } + return Promise.resolve([{ state: 'ENABLED' }]); + }); + + await ensureApisEnabled( + { serviceUsageClient: mockServiceUsageClient }, + projectId, + apis, + mockProgressCallback + ); + + // getService should be called for serviceusage, cloudbilling, and the test api + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 3); + // enableService should only be called for the test api + assert.strictEqual( + mockServiceUsageClient.enableService.mock.callCount(), + 1 + ); + assert.match( + mockServiceUsageClient.enableService.mock.calls[0].arguments[0].name, + /test-api\.googleapis\.com/ + ); + // isBillingEnabled should be called once + assert.strictEqual(billingMocks.isBillingEnabled.mock.callCount(), 1); + }); }); describe('Billing Enabled', () => { - it('should do nothing if API is already enabled', async () => { + it('should do nothing if all APIs are already enabled', async () => { billingMocks.isBillingEnabled.mock.mockImplementation(() => Promise.resolve(true) ); @@ -63,7 +157,7 @@ describe('ensureApisEnabled', () => { mockProgressCallback ); - assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 1); + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 3); assert.strictEqual( mockServiceUsageClient.enableService.mock.callCount(), 0 @@ -74,9 +168,12 @@ describe('ensureApisEnabled', () => { billingMocks.isBillingEnabled.mock.mockImplementation(() => Promise.resolve(true) ); - mockServiceUsageClient.getService.mock.mockImplementation(() => - Promise.resolve([{ state: 'DISABLED' }]) - ); + mockServiceUsageClient.getService.mock.mockImplementation(({ name }) => { + if (name.includes('test-api')) { + return Promise.resolve([{ state: 'DISABLED' }]); + } + return Promise.resolve([{ state: 'ENABLED' }]); + }); await ensureApisEnabled( { serviceUsageClient: mockServiceUsageClient }, @@ -85,7 +182,7 @@ describe('ensureApisEnabled', () => { mockProgressCallback ); - assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 1); + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 3); assert.strictEqual( mockServiceUsageClient.enableService.mock.callCount(), 1 @@ -97,7 +194,12 @@ describe('ensureApisEnabled', () => { Promise.resolve(true) ); let getServiceCallCount = 0; - mockServiceUsageClient.getService.mock.mockImplementation(() => { + mockServiceUsageClient.getService.mock.mockImplementation(({ name }) => { + // Ensure pre-checks pass + if (name.includes('serviceusage') || name.includes('cloudbilling')) { + return Promise.resolve([{ state: 'ENABLED' }]); + } + // Fail only for test-api on the first attempt getServiceCallCount++; if (getServiceCallCount === 1) { return Promise.reject(new Error('First fail')); @@ -112,7 +214,7 @@ describe('ensureApisEnabled', () => { mockProgressCallback ); - assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 2); + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 4); // 2 pre-checks + 2 attempts for test-api assert.strictEqual( mockServiceUsageClient.enableService.mock.callCount(), 0 @@ -123,9 +225,14 @@ describe('ensureApisEnabled', () => { billingMocks.isBillingEnabled.mock.mockImplementation(() => Promise.resolve(true) ); - mockServiceUsageClient.getService.mock.mockImplementation(() => - Promise.reject(new Error('Always fail')) - ); + mockServiceUsageClient.getService.mock.mockImplementation(({ name }) => { + // Ensure pre-checks pass + if (name.includes('serviceusage') || name.includes('cloudbilling')) { + return Promise.resolve([{ state: 'ENABLED' }]); + } + // Fail only for test-api + return Promise.reject(new Error('Always fail')); + }); await assert.rejects( () => @@ -140,7 +247,7 @@ describe('ensureApisEnabled', () => { 'Failed to ensure API [test-api.googleapis.com] is enabled after retry. Please check manually.', } ); - assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 2); + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 4); // 2 pre-checks + 2 attempts for test-api }); }); @@ -152,6 +259,9 @@ describe('ensureApisEnabled', () => { billingMocks.listBillingAccounts.mock.mockImplementation(() => Promise.resolve([]) ); + mockServiceUsageClient.getService.mock.mockImplementation(() => + Promise.resolve([{ state: 'ENABLED' }]) + ); await assert.rejects( () => @@ -174,6 +284,9 @@ describe('ensureApisEnabled', () => { billingMocks.listBillingAccounts.mock.mockImplementation(() => Promise.resolve([{}, {}]) ); + mockServiceUsageClient.getService.mock.mockImplementation(() => + Promise.resolve([{ state: 'ENABLED' }]) + ); await assert.rejects( () => @@ -196,6 +309,9 @@ describe('ensureApisEnabled', () => { billingMocks.listBillingAccounts.mock.mockImplementation(() => Promise.resolve([{ displayName: 'Closed Account', open: false }]) ); + mockServiceUsageClient.getService.mock.mockImplementation(() => + Promise.resolve([{ state: 'ENABLED' }]) + ); await assert.rejects( () => @@ -242,7 +358,7 @@ describe('ensureApisEnabled', () => { billingMocks.attachProjectToBillingAccount.mock.callCount(), 1 ); - assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 1); + assert.strictEqual(mockServiceUsageClient.getService.mock.callCount(), 3); assert.strictEqual( mockServiceUsageClient.enableService.mock.callCount(), 0 @@ -265,6 +381,9 @@ describe('ensureApisEnabled', () => { billingMocks.attachProjectToBillingAccount.mock.mockImplementation(() => Promise.resolve({ billingEnabled: false }) ); + mockServiceUsageClient.getService.mock.mockImplementation(() => + Promise.resolve([{ state: 'ENABLED' }]) + ); await assert.rejects( () => diff --git a/test/need-gcp/test-helpers.js b/test/need-gcp/test-helpers.js index b071f44e..fb374e05 100644 --- a/test/need-gcp/test-helpers.js +++ b/test/need-gcp/test-helpers.js @@ -163,7 +163,10 @@ export async function setSourceDeployProjectPermissions(projectId) { const context = { serviceUsageClient: serviceUsageClient, }; - await ensureApisEnabled(context, projectId, ['run.googleapis.com']); + await ensureApisEnabled(context, projectId, [ + 'run.googleapis.com', + 'cloudbilling.googleapis.com', + ]); console.log('Adding editor role to Compute SA...'); const projectNumber = await getProjectNumber(projectId); const member = `serviceAccount:${projectNumber}-compute@developer.gserviceaccount.com`;