Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 66 additions & 33 deletions lib/cloud-api/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>} 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.
Expand All @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 4 additions & 2 deletions lib/deployment/deployer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand Down
145 changes: 132 additions & 13 deletions test/local/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand All @@ -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
Expand All @@ -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 },
Expand All @@ -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
Expand All @@ -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'));
Expand All @@ -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
Expand All @@ -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(
() =>
Expand All @@ -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
});
});

Expand All @@ -152,6 +259,9 @@ describe('ensureApisEnabled', () => {
billingMocks.listBillingAccounts.mock.mockImplementation(() =>
Promise.resolve([])
);
mockServiceUsageClient.getService.mock.mockImplementation(() =>
Promise.resolve([{ state: 'ENABLED' }])
);

await assert.rejects(
() =>
Expand All @@ -174,6 +284,9 @@ describe('ensureApisEnabled', () => {
billingMocks.listBillingAccounts.mock.mockImplementation(() =>
Promise.resolve([{}, {}])
);
mockServiceUsageClient.getService.mock.mockImplementation(() =>
Promise.resolve([{ state: 'ENABLED' }])
);

await assert.rejects(
() =>
Expand All @@ -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(
() =>
Expand Down Expand Up @@ -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
Expand All @@ -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(
() =>
Expand Down
5 changes: 4 additions & 1 deletion test/need-gcp/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down