packages/utils/blueprint-cli/src/publish/upload-blueprint.ts (322 lines of code) (raw):
import * as fs from 'fs';
import * as querystring from 'querystring';
import * as axios from 'axios';
import axiosRetry, { exponentialDelay } from 'axios-retry';
import * as pino from 'pino';
import { blueprintVersionExists } from './blueprint-version-exists';
import { CodeCatalystAuthentication, generateHeaders } from './codecatalyst-authentication';
import { deleteBlueprintVersion } from './delete-blueprint-version';
import { IdentityResponse } from './verify-identity';
export async function uploadBlueprint(
log: pino.BaseLogger,
packagePath: string,
endpoint: string,
options: {
force?: boolean;
blueprint: {
publishingSpace: string;
targetSpace: string;
targetProject?: string;
targetInstance?: string;
packageName: string;
version: string;
authentication: CodeCatalystAuthentication;
identity: IdentityResponse | undefined;
};
},
) {
const { blueprint } = options;
const auth = {
authentication: blueprint.authentication,
identity: blueprint.identity,
};
const target = {
package: blueprint.packageName,
space: blueprint.targetSpace,
version: blueprint.version,
};
log.info(`Starting publishing blueprint package to ['${blueprint.packageName}'] ['${blueprint.version}'] to ['${blueprint.targetSpace}'].`);
if (
await blueprintVersionExists(log, endpoint, {
blueprint: target,
auth,
})
) {
log.warn(
`Blueprint version ['${blueprint.packageName}'] ['${blueprint.version}'] EXISTS in ['${blueprint.targetSpace}']. Run with --force to override`,
);
if (options.force) {
log.warn(`[FORCE] running in force mode. Deleting ['${blueprint.packageName}'] ['${blueprint.version}'] in ['${blueprint.targetSpace}'].`);
await deleteBlueprintVersion(log, endpoint, {
blueprint: target,
auth,
});
log.warn(`[FORCE] Deletion ['${blueprint.packageName}'] ['${blueprint.version}'] in ['${blueprint.targetSpace}'] successful.`);
} else {
throw `Blueprint ['${blueprint.packageName}'] ['${blueprint.version}'] already exists in ['${blueprint.targetSpace}']. Change the package version or run with --force to override.`;
}
}
log.info(`Generating a readstream to ${packagePath}`);
const blueprintTarballStream = fs.createReadStream(packagePath);
const publishHeaders = {
'authority': endpoint,
'origin': `https://${endpoint}`,
'Content-Type': 'application/octet-stream',
'Content-Length': fs.statSync(packagePath).size,
...generateHeaders(blueprint.authentication, blueprint.identity),
};
const url = `https://${endpoint}/v1/spaces/${querystring.escape(blueprint.targetSpace)}/blueprints/${querystring.escape(
blueprint.packageName,
)}/versions/${querystring.escape(blueprint.version)}/packages`;
const maxRetries = 2;
const publishAxios = axios.default.create();
axiosRetry(publishAxios, {
retries: maxRetries,
shouldResetTimeout: true,
retryDelay: exponentialDelay,
retryCondition: error => {
switch (error.response?.status) {
case 500:
return true;
default:
return false;
}
},
onRetry: (retryCount, error) => {
console.log(`publishing attempt ${retryCount}/${maxRetries + 1} failed: ${error.message}...`);
console.log('retrying...');
},
onMaxRetryTimesExceeded: error => {
console.log(`publishing attempt ${maxRetries}/${maxRetries + 1} failed: ${error.message}...`);
console.log('all publishing attempts have failed');
},
});
const publishBlueprintPackageResponse = await publishAxios({
method: 'PUT',
url,
data: blueprintTarballStream,
headers: publishHeaders,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
console.log({
requestId: publishBlueprintPackageResponse.headers['x-amzn-requestid'],
...publishBlueprintPackageResponse.data,
url,
servedFrom: publishBlueprintPackageResponse.headers['x-amzn-served-from'],
});
log.info('Attempting to publish', {
data: publishBlueprintPackageResponse.data,
});
const baseWaitSec = 5;
const attempts = 100;
const { spaceName, blueprintName, version, statusId } = publishBlueprintPackageResponse.data;
for (let attempt = 0; attempt < attempts; attempt++) {
const fetchStatusResponse = await fetchstatus(log, {
input: {
spaceName,
id: statusId,
version,
blueprintName,
},
http: {
endpoint,
headers: generateHeaders(blueprint.authentication, blueprint.identity),
},
});
log.info(`[${attempt}/${attempts}] Status: ${fetchStatusResponse.status}`);
if (fetchStatusResponse.status === 'SUCCEEDED') {
log.info('Blueprint published successfully');
const previewOptions = {
blueprintPackage: blueprint.packageName,
version: blueprint.version,
publishingSpace: blueprint.publishingSpace,
targetSpace: blueprint.targetSpace,
targetProject: blueprint.targetProject,
targetInstance: blueprint.targetInstance,
http: {
endpoint: endpoint,
headers: generateHeaders(blueprint.authentication, blueprint.identity),
},
};
log.info(`Enable version ${version} at: ${resolveStageUrl(endpoint)}/spaces/${blueprint.targetSpace}/blueprints`);
const previewlink = await generatePreviewLink(log, previewOptions);
if (previewOptions.targetProject) {
log.info(`Blueprint applied to [${previewOptions.targetProject}]: ${previewlink}`);
} else {
log.info(`Blueprint applied to [NEW]: ${previewlink}`);
}
return;
} else if (fetchStatusResponse.status === 'IN_PROGRESS') {
const curWait = baseWaitSec * 1000 + 1000 * attempt;
log.debug(`[${attempt}/${attempts}] Waiting ${curWait / 1000} seconds...`);
await sleep(curWait);
} else {
//break on failed or cancelled publishing jobs
log.info(`Blueprint publish job status is '${fetchStatusResponse.status}' due to '${fetchStatusResponse.reason}'`);
break;
}
}
log.info(`Blueprint has not published successfully. Id: ${statusId}`);
}
interface FetchStatusResult {
success: boolean;
status: string;
reason?: string;
}
async function fetchstatus(
_log: pino.BaseLogger,
options: {
input: {
spaceName: string;
id: string;
blueprintName: string;
version: string;
};
http: {
endpoint;
headers: { [key: string]: string };
};
},
): Promise<FetchStatusResult> {
const input = {
spaceName: options.input.spaceName,
id: options.input.id,
blueprintName: options.input.blueprintName,
version: options.input.version,
};
const response = await axios.default.post(
`https://${options.http.endpoint}/graphql?`,
{
query:
'query GetBlueprintVersionStatus($input: GetBlueprintVersionStatusInput!) {\n getBlueprintVersionStatus(input: $input) {\n spaceName\n id\n blueprintName\n version\n status\n }\n}',
variables: {
input,
},
operationName: 'GetBlueprintVersionStatus',
},
{
headers: {
'authority': options.http.endpoint,
'origin': `https://${options.http.endpoint}`,
'accept': 'application/json',
'content-type': 'application/json',
...options.http.headers,
},
},
);
const responseStatus = response.data?.data?.getBlueprintVersionStatus;
if (responseStatus.status === 'SUCCEEDED') {
return {
success: true,
status: responseStatus.status,
};
} else if (responseStatus.status === 'FAILED') {
return {
success: false,
status: responseStatus.status,
reason: responseStatus.reason ?? 'an internal error has occurred',
};
} else if (responseStatus.status === 'CANCELLED') {
return {
success: false,
status: responseStatus.status,
reason: responseStatus.reason ?? 'The publishing job has been cancelled',
};
}
return {
success: false,
status: responseStatus.status ?? 'UNKNOWN',
};
}
function sleep(milliseconds: number) {
return new Promise(resolve => {
setTimeout(resolve, milliseconds);
});
}
async function generatePreviewLink(
_logger: pino.BaseLogger,
options: {
blueprintPackage: string;
version: string;
publishingSpace: string;
targetSpace: string;
targetProject?: string;
targetInstance?: string;
http: {
endpoint;
headers: { [key: string]: string };
};
},
): Promise<string> {
const publishingSpaceIdResponse = await axios.default.post(
`https://${options.http.endpoint}/graphql?`,
{
operationName: 'GetSpace',
variables: {
input: {
name: options.publishingSpace,
},
},
query: 'query GetSpace($input: GetSpaceInput!) {\n getSpace(input: $input) {\n id\n name\n }\n}\n',
},
{
headers: {
'authority': options.http.endpoint,
'origin': `https://${options.http.endpoint}`,
'accept': 'application/json',
'content-type': 'application/json',
...options.http.headers,
},
},
);
if (options.targetProject && options.targetInstance) {
/**
* generate a url to preview against an existing instance
*/
return [
resolveStageUrl(options.http.endpoint),
'spaces',
querystring.escape(options.targetSpace),
'projects',
querystring.escape(options.targetProject),
'blueprints',
querystring.escape(options.blueprintPackage),
'publishers',
querystring.escape(publishingSpaceIdResponse.data?.data?.getSpace?.id),
'versions',
querystring.escape(options.version),
`edit?instantiationId=${options.targetInstance}`,
].join('/');
} else if (options.targetProject) {
/**
* generate a url to add project
*/
return [
resolveStageUrl(options.http.endpoint),
'spaces',
querystring.escape(options.targetSpace),
'projects',
querystring.escape(options.targetProject),
'blueprints',
querystring.escape(options.blueprintPackage),
'publishers',
querystring.escape(publishingSpaceIdResponse.data?.data?.getSpace?.id),
'versions',
querystring.escape(options.version),
'add',
].join('/');
}
return [
resolveStageUrl(options.http.endpoint),
'spaces',
querystring.escape(options.targetSpace),
'blueprints',
querystring.escape(options.blueprintPackage),
'publishers',
querystring.escape(publishingSpaceIdResponse.data?.data?.getSpace?.id),
'versions',
querystring.escape(options.version),
'projects/create',
].join('/');
}
function resolveStageUrl(endpoint: string): string {
if (endpoint.endsWith('api-gamma.quokka.codes')) {
return 'https://integ.stage.quokka.codes';
} else {
return 'https://codecatalyst.aws';
}
}