From d089fce9a7dbc18e0b9cf25ec54e2f8c8c1e55f0 Mon Sep 17 00:00:00 2001 From: Riddhi Shivhare Date: Tue, 25 Nov 2025 15:05:54 +0530 Subject: [PATCH 1/6] Added Cloud Billing API to the Apis list and also added a check before checking for the billing account status --- lib/cloud-api/helpers.js | 20 ++++++++++++++++++++ lib/deployment/deployer.js | 2 ++ test/need-gcp/test-helpers.js | 5 ++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/cloud-api/helpers.js b/lib/cloud-api/helpers.js index 22f7954c..cc7e0de3 100644 --- a/lib/cloud-api/helpers.js +++ b/lib/cloud-api/helpers.js @@ -110,6 +110,26 @@ export async function ensureApisEnabled( apis, progressCallback ) { + // Ensure Cloud Billing API is enabled before checking billing status, + // otherwise isBillingEnabled will fail. + try { + const billingApi = 'cloudbilling.googleapis.com'; + const serviceName = `projects/${projectId}/services/${billingApi}`; + console.log('Ensuring Cloud Billing API is enabled...'); + await checkAndEnableApi( + context.serviceUsageClient, + serviceName, + billingApi, + progressCallback + ); + } catch (e) { + const errorMessage = `Failed to enable Cloud Billing API: ${e.message}`; + console.error(errorMessage, e); + if (progressCallback) + progressCallback({ level: 'error', data: errorMessage }); + throw new Error(errorMessage); + } + if (!(await isBillingEnabled(projectId))) { // Billing is disabled, try to fix it. const accounts = await listBillingAccounts(); diff --git a/lib/deployment/deployer.js b/lib/deployment/deployer.js index 03d9054e..6cf6b8db 100644 --- a/lib/deployment/deployer.js +++ b/lib/deployment/deployer.js @@ -33,6 +33,7 @@ const IMAGE_TAG = 'latest'; // APIs required for deploying from source code. const REQUIRED_APIS_FOR_SOURCE_DEPLOY = [ 'iam.googleapis.com', + 'cloudbilling.googleapis.com', 'storage.googleapis.com', 'cloudbuild.googleapis.com', 'artifactregistry.googleapis.com', @@ -42,6 +43,7 @@ const REQUIRED_APIS_FOR_SOURCE_DEPLOY = [ // APIs required for deploying a container image. const REQUIRED_APIS_FOR_IMAGE_DEPLOY = [ + 'cloudbilling.googleapis.com', 'run.googleapis.com', 'serviceusage.googleapis.com', ]; 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`; From 55edc57e494a1c4f33b1c4916bd063204849d3f6 Mon Sep 17 00:00:00 2001 From: Riddhi Shivhare Date: Tue, 25 Nov 2025 15:15:16 +0530 Subject: [PATCH 2/6] Added Cloud Billing API to ensureApisEnabled --- lib/cloud-api/run.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/cloud-api/run.js b/lib/cloud-api/run.js index 3ea96071..a8644618 100644 --- a/lib/cloud-api/run.js +++ b/lib/cloud-api/run.js @@ -61,7 +61,10 @@ export async function listServices(projectId) { runClient: runClient, serviceUsageClient: serviceUsageClient, }; - await ensureApisEnabled(context, projectId, ['run.googleapis.com']); + await ensureApisEnabled(context, projectId, [ + 'run.googleapis.com', + 'cloudbilling.googleapis.com', + ]); const locations = await listCloudRunLocations(projectId); const allServices = {}; From 5c7c5a69fe0b83d03e75147502f393c821fceb90 Mon Sep 17 00:00:00 2001 From: Riddhi Shivhare Date: Tue, 25 Nov 2025 15:51:52 +0530 Subject: [PATCH 3/6] Added Service Usage to enable other APIs --- lib/cloud-api/helpers.js | 17 +++++ test/local/helpers.test.js | 145 +++++++++++++++++++++++++++++++++---- 2 files changed, 149 insertions(+), 13 deletions(-) diff --git a/lib/cloud-api/helpers.js b/lib/cloud-api/helpers.js index cc7e0de3..e52d8a50 100644 --- a/lib/cloud-api/helpers.js +++ b/lib/cloud-api/helpers.js @@ -110,6 +110,23 @@ export async function ensureApisEnabled( apis, progressCallback ) { + // We need Service Usage API to check and enable other APIs. + try { + const serviceUsageApi = 'serviceusage.googleapis.com'; + const serviceName = `projects/${projectId}/services/${serviceUsageApi}`; + await checkAndEnableApi( + context.serviceUsageClient, + serviceName, + serviceUsageApi, + progressCallback + ); + } catch (e) { + const errorMessage = `Failed to enable Service Usage API: ${e.message}`; + console.error(errorMessage, e); + if (progressCallback) + progressCallback({ level: 'error', data: errorMessage }); + throw new Error(errorMessage); + } // Ensure Cloud Billing API is enabled before checking billing status, // otherwise isBillingEnabled will fail. try { 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( () => From 03df91c4429987d5e5bb84c4e8226c9579310119 Mon Sep 17 00:00:00 2001 From: Riddhi Shivhare Date: Tue, 25 Nov 2025 16:31:27 +0530 Subject: [PATCH 4/6] Created enableApisWithRetry function to enable apis --- lib/cloud-api/helpers.js | 140 ++++++++++++++++++++------------------- lib/cloud-api/run.js | 5 +- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/lib/cloud-api/helpers.js b/lib/cloud-api/helpers.js index e52d8a50..41015b2d 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,42 +163,17 @@ export async function ensureApisEnabled( apis, progressCallback ) { - // We need Service Usage API to check and enable other APIs. - try { - const serviceUsageApi = 'serviceusage.googleapis.com'; - const serviceName = `projects/${projectId}/services/${serviceUsageApi}`; - await checkAndEnableApi( - context.serviceUsageClient, - serviceName, - serviceUsageApi, - progressCallback - ); - } catch (e) { - const errorMessage = `Failed to enable Service Usage API: ${e.message}`; - console.error(errorMessage, e); - if (progressCallback) - progressCallback({ level: 'error', data: errorMessage }); - throw new Error(errorMessage); - } - // Ensure Cloud Billing API is enabled before checking billing status, - // otherwise isBillingEnabled will fail. - try { - const billingApi = 'cloudbilling.googleapis.com'; - const serviceName = `projects/${projectId}/services/${billingApi}`; - console.log('Ensuring Cloud Billing API is enabled...'); - await checkAndEnableApi( - context.serviceUsageClient, - serviceName, - billingApi, - progressCallback - ); - } catch (e) { - const errorMessage = `Failed to enable Cloud Billing API: ${e.message}`; - console.error(errorMessage, e); - if (progressCallback) - progressCallback({ level: 'error', data: errorMessage }); - throw new Error(errorMessage); - } + 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. @@ -195,39 +223,15 @@ 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); - } - } + // If apis is not an array, default to empty array. + if (!Array.isArray(apis)) { + console.warn( + 'ensureApisEnabled: apis parameter is not an array, defaulting to empty array' + ); + apis = []; } + + 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/cloud-api/run.js b/lib/cloud-api/run.js index a8644618..3ea96071 100644 --- a/lib/cloud-api/run.js +++ b/lib/cloud-api/run.js @@ -61,10 +61,7 @@ export async function listServices(projectId) { runClient: runClient, serviceUsageClient: serviceUsageClient, }; - await ensureApisEnabled(context, projectId, [ - 'run.googleapis.com', - 'cloudbilling.googleapis.com', - ]); + await ensureApisEnabled(context, projectId, ['run.googleapis.com']); const locations = await listCloudRunLocations(projectId); const allServices = {}; From 460926b830ff53e260573e145361defa7d767291 Mon Sep 17 00:00:00 2001 From: Riddhi Shivhare Date: Tue, 25 Nov 2025 16:42:11 +0530 Subject: [PATCH 5/6] Removed empty array as the function is always called with list of apis --- lib/cloud-api/helpers.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/cloud-api/helpers.js b/lib/cloud-api/helpers.js index 41015b2d..62721f47 100644 --- a/lib/cloud-api/helpers.js +++ b/lib/cloud-api/helpers.js @@ -223,14 +223,6 @@ export async function ensureApisEnabled( console.log(message); if (progressCallback) progressCallback({ level: 'info', data: message }); - // If apis is not an array, default to empty array. - if (!Array.isArray(apis)) { - console.warn( - 'ensureApisEnabled: apis parameter is not an array, defaulting to empty array' - ); - apis = []; - } - await enableApisWithRetry(context, projectId, apis, progressCallback); const successMsg = 'All required APIs are enabled.'; console.log(successMsg); From a181ecf0cd94a97cc0dba829b3260838d630914d Mon Sep 17 00:00:00 2001 From: Riddhi Shivhare Date: Wed, 26 Nov 2025 01:31:53 +0530 Subject: [PATCH 6/6] Adding Service Usage API first in the order of List. --- lib/deployment/deployer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/deployment/deployer.js b/lib/deployment/deployer.js index 6cf6b8db..a2b7a496 100644 --- a/lib/deployment/deployer.js +++ b/lib/deployment/deployer.js @@ -32,20 +32,20 @@ const IMAGE_TAG = 'latest'; // APIs required for deploying from source code. const REQUIRED_APIS_FOR_SOURCE_DEPLOY = [ - 'iam.googleapis.com', + '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 = [ + 'serviceusage.googleapis.com', 'cloudbilling.googleapis.com', 'run.googleapis.com', - 'serviceusage.googleapis.com', ]; /**