Skip to content
19 changes: 14 additions & 5 deletions packages/spacecat-shared-cloud-manager-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import { archiveFolder, extract } from 'zip-lib';
const GIT_BIN = process.env.GIT_BIN_PATH || '/opt/bin/git';
const CLONE_DIR_PREFIX = 'cm-repo-';
const PATCH_FILE_PREFIX = 'cm-patch-';
const GIT_OPERATION_TIMEOUT_MS = 120_000; // 120s — fail fast before Lambda timeout

// Per-operation timeout for git commands (clone, push, pull, commit, etc.).
// Override via GIT_OPERATION_TIMEOUT_MS env var. Defaults to 10 min so large
// repositories can finish cloning within the Lambda's 15-min envelope.
const GIT_OPERATION_TIMEOUT_MS = parseInt(process.env.GIT_OPERATION_TIMEOUT_MS, 10) || 600_000;

/**
* Repository type constants for Cloud Manager integrations.
Expand Down Expand Up @@ -260,7 +264,9 @@ export default class CloudManagerClient {
* Builds authenticated git arguments for a remote command (clone, push, or pull).
*
* Both repo types use http.extraheader for authentication:
* - Standard repos: Basic auth header via extraheader on the repo URL
* - Standard repos: Basic auth header via extraheader scoped to the org prefix
* (scheme + host + '/' + orgName + '/'), so the header covers all repos and submodules
* belonging to that customer org without granting access to other orgs on the same host
* - BYOG repos: Bearer token + API key + IMS org ID via extraheader on the CM Repo URL
*
* @param {string} command - The git command ('clone', 'push', or 'pull')
Expand All @@ -276,8 +282,11 @@ export default class CloudManagerClient {
if (repoType === CM_REPO_TYPE.STANDARD) {
const credentials = this.#getStandardRepoCredentials(programId);
const basicAuth = Buffer.from(credentials).toString('base64');
const parsedUrl = new URL(repoUrl);
const orgName = parsedUrl.pathname.split('/')[1];
const repoOrgPrefix = `${parsedUrl.origin}/${orgName}/`;
return [
'-c', `http.${repoUrl}.extraheader=Authorization: Basic ${basicAuth}`,
'-c', `http.${repoOrgPrefix}.extraheader=Authorization: Basic ${basicAuth}`,
command, repoUrl,
];
}
Expand Down Expand Up @@ -316,7 +325,7 @@ export default class CloudManagerClient {
this.log.info(`Cloning CM repository: program=${programId}, repo=${repositoryId}, type=${repoType}`);

const args = await this.#buildAuthGitArgs('clone', programId, repositoryId, { imsOrgId, repoType, repoUrl });
this.#execGit([...args, clonePath]);
this.#execGit([...args, '--recurse-submodules', clonePath]);
this.log.info(`Repository cloned to ${clonePath}`);
this.#logTmpDiskUsage('clone');

Expand Down Expand Up @@ -581,7 +590,7 @@ export default class CloudManagerClient {
this.log.info(`Checked out ref '${ref}' before pull`);
}
const pullArgs = await this.#buildAuthGitArgs('pull', programId, repositoryId, { imsOrgId, repoType, repoUrl });
this.#execGit(pullArgs, { cwd: clonePath });
this.#execGit([...pullArgs, '--recurse-submodules'], { cwd: clonePath });
this.log.info('Changes pulled successfully');
this.#logTmpDiskUsage('pull');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,27 @@ describe('CloudManagerClient', () => {
const cloneArgs = getGitArgs(execFileSyncStub.firstCall);
const cloneArgsStr = getGitArgsStr(execFileSyncStub.firstCall);
expect(cloneArgs).to.include('clone');
expect(cloneArgsStr).to.include(`http.${TEST_STANDARD_REPO_URL}.extraheader=Authorization: Basic c3RkdXNlcjpzdGR0b2tlbjEyMw==`);
expect(cloneArgsStr).to.include('http.https://git.cloudmanager.adobe.com/myorg/.extraheader=Authorization: Basic c3RkdXNlcjpzdGR0b2tlbjEyMw==');
expect(cloneArgsStr).to.include(TEST_STANDARD_REPO_URL);
expect(cloneArgs).to.include(EXPECTED_CLONE_PATH);
// No credentials in the URL itself
expect(cloneArgsStr).to.not.include('stduser:stdtoken123@');
expect(cloneArgsStr).to.not.include('Bearer');
});

it('includes --recurse-submodules in the clone arguments', async () => {
const client = CloudManagerClient.createFrom(createContext());

await client.clone(
TEST_PROGRAM_ID,
TEST_REPO_ID,
{ imsOrgId: TEST_IMS_ORG_ID },
);

const gitArgs = getGitArgs(execFileSyncStub.firstCall);
expect(gitArgs).to.include('--recurse-submodules');
});

it('throws when standard credentials not found for programId', async () => {
const client = CloudManagerClient.createFrom(
createContext({ CM_STANDARD_REPO_CREDENTIALS: TEST_STANDARD_CREDENTIALS }),
Expand Down Expand Up @@ -378,9 +391,9 @@ describe('CloudManagerClient', () => {
const client = CloudManagerClient.createFrom(context);

await expect(client.clone(TEST_PROGRAM_ID, TEST_REPO_ID, { imsOrgId: TEST_IMS_ORG_ID }))
.to.be.rejectedWith('Git command timed out after 120s');
.to.be.rejectedWith(/^Git command timed out after \d+s$/);

expect(context.log.error.firstCall.args[0]).to.include('timed out after 120s');
expect(context.log.error.firstCall.args[0]).to.match(/timed out after \d+s/);
});

it('sanitizes Bearer token and credentials in git error output', async () => {
Expand Down Expand Up @@ -784,7 +797,7 @@ describe('CloudManagerClient', () => {
const pushArgs = getGitArgs(execFileSyncStub.firstCall);
const pushArgStr = getGitArgsStr(execFileSyncStub.firstCall);
expect(pushArgStr).to.include('push');
expect(pushArgStr).to.include(`http.${TEST_STANDARD_REPO_URL}.extraheader=Authorization: Basic c3RkdXNlcjpzdGR0b2tlbjEyMw==`);
expect(pushArgStr).to.include('http.https://git.cloudmanager.adobe.com/myorg/.extraheader=Authorization: Basic c3RkdXNlcjpzdGR0b2tlbjEyMw==');
expect(pushArgStr).to.include(TEST_STANDARD_REPO_URL);
expect(pushArgStr).to.not.include('stduser:stdtoken123@');
expect(pushArgStr).to.not.include('Bearer');
Expand Down Expand Up @@ -823,6 +836,7 @@ describe('CloudManagerClient', () => {

const pullArgStr = getGitArgsStr(execFileSyncStub.firstCall);
expect(pullArgStr).to.include('pull');
expect(pullArgStr).to.include('--recurse-submodules');
expect(pullArgStr).to.include(`Authorization: Bearer ${TEST_TOKEN}`);
expect(pullArgStr).to.include('x-api-key: test-client-id');
expect(pullArgStr).to.include(`x-gw-ims-org-id: ${TEST_IMS_ORG_ID}`);
Expand All @@ -846,7 +860,8 @@ describe('CloudManagerClient', () => {

const pullArgStr = getGitArgsStr(execFileSyncStub.firstCall);
expect(pullArgStr).to.include('pull');
expect(pullArgStr).to.include(`http.${TEST_STANDARD_REPO_URL}.extraheader=Authorization: Basic c3RkdXNlcjpzdGR0b2tlbjEyMw==`);
expect(pullArgStr).to.include('--recurse-submodules');
expect(pullArgStr).to.include('http.https://git.cloudmanager.adobe.com/myorg/.extraheader=Authorization: Basic c3RkdXNlcjpzdGR0b2tlbjEyMw==');
expect(pullArgStr).to.include(TEST_STANDARD_REPO_URL);
expect(pullArgStr).to.not.include('stduser:stdtoken123@');
expect(pullArgStr).to.not.include('Bearer');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const schema = new SchemaBuilder(Site, SiteCollection)
installationId: { type: 'string', required: false },
url: { type: 'string', required: true, validate: (value) => isValidUrl(value) },
s3StoragePath: { type: 'string', required: false },
hasSubmodules: { type: 'boolean', required: false },
},
})
.addAttribute('deliveryType', {
Expand Down
Loading