@aws-amplify/amplify-category-api
Version:
Amplify CLI API Category Plugin
363 lines (295 loc) • 12.6 kB
text/typescript
import * as path from 'path';
import { Octokit } from '@octokit/rest';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import { v4 as uuid } from 'uuid';
import { $TSContext, JSONUtilities, pathManager, readCFNTemplate } from '@aws-amplify/amplify-cli-core';
import * as cdk from 'aws-cdk-lib';
import { provider as cloudformationProviderName } from '../../../provider-utils/awscloudformation/aws-constants';
import { getContainers } from '../../../provider-utils/awscloudformation/docker-compose';
import Container from '../docker-compose/ecs-objects/container';
import { EcsStack } from '../ecs-apigw-stack';
import { API_TYPE, ResourceDependency } from '../../../provider-utils/awscloudformation/service-walkthroughs/containers-walkthrough';
import { getGitHubOwnerRepoFromPath } from '../../../provider-utils/awscloudformation/utils/github';
import { DEPLOYMENT_MECHANISM } from '../base-api-stack';
import { category } from '../../../category-constants';
import { setExistingSecretArns } from './containers/set-existing-secret-arns';
export const cfnFileName = (resourceName: string) => `${resourceName}-cloudformation-template.json`;
export type ApiResource = {
category: string;
resourceName: string;
gitHubInfo?: {
path: string;
tokenSecretArn: string;
};
deploymentMechanism: DEPLOYMENT_MECHANISM;
authName: string;
restrictAccess: boolean;
dependsOn: ResourceDependency[];
environmentMap: Record<string, string>;
categoryPolicies: any[];
mutableParametersState: any;
output?: Record<string, any>;
apiType?: API_TYPE;
exposedContainer?: { name: string; port: number };
};
type ExposedContainer = {
name: string;
port: number;
};
type ContainerArtifactsMetadata = {
exposedContainer: ExposedContainer;
pipelineInfo: { consoleUrl: string };
};
export async function generateContainersArtifacts(
context: $TSContext,
resource: ApiResource,
askForExposedContainer: boolean = false,
): Promise<ContainerArtifactsMetadata> {
const {
providers: { [cloudformationProviderName]: provider },
} = context.amplify.getProjectMeta();
const { StackName: envName } = provider;
const {
category: categoryName,
resourceName,
gitHubInfo,
deploymentMechanism,
categoryPolicies = [],
dependsOn,
environmentMap,
restrictAccess,
apiType,
} = resource;
const backendDir = context.amplify.pathManager.getBackendDirPath();
const resourceDir = path.normalize(path.join(backendDir, categoryName, resourceName));
const srcPath = path.join(resourceDir, 'src');
const { containersPorts, containers, isInitialDeploy, desiredCount, exposedContainer, secretsArns } = await processDockerConfig(
context,
resource,
srcPath,
askForExposedContainer,
);
const repositories = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'describeEcrRepositories');
const existingEcrRepositories: Set<string> = new Set(
repositories
.map(({ repositoryName }) => repositoryName)
.filter((repositoryName) => repositoryName.startsWith(`${envName}-${categoryName}-${resourceName}-`)),
);
const stack = new EcsStack(undefined, 'ContainersStack', {
categoryName,
apiName: resourceName,
taskPorts: containersPorts,
dependsOn,
policies: categoryPolicies,
taskEnvironmentVariables: environmentMap,
gitHubSourceActionInfo: gitHubInfo,
deploymentMechanism,
containers,
isInitialDeploy,
desiredCount,
restrictAccess,
currentStackName: envName,
apiType,
exposedContainer,
secretsArns,
existingEcrRepositories,
});
const cfn = stack.toCloudFormation();
JSONUtilities.writeJson(path.normalize(path.join(resourceDir, cfnFileName(resourceName))), cfn);
return {
exposedContainer,
pipelineInfo: { consoleUrl: stack.getPipelineConsoleUrl(provider.Region) },
};
}
export async function processDockerConfig(
context: $TSContext,
resource: ApiResource,
srcPath: string,
askForExposedContainer: boolean = false,
) {
const {
providers: { [cloudformationProviderName]: provider },
} = context.amplify.getProjectMeta();
const { StackName: envName } = provider;
const { resourceName, gitHubInfo, deploymentMechanism, output, exposedContainer: exposedContainerFromMeta } = resource;
const dockerComposeFileNameYaml = 'docker-compose.yaml';
const dockerComposeFileNameYml = 'docker-compose.yml';
const dockerfileFileName = 'Dockerfile';
const containerDefinitionFileNames = [dockerComposeFileNameYaml, dockerComposeFileNameYml, dockerfileFileName];
const containerDefinitionFiles: Record<string, string> = {};
for await (const fileName of containerDefinitionFileNames) {
switch (deploymentMechanism) {
case DEPLOYMENT_MECHANISM.FULLY_MANAGED:
case DEPLOYMENT_MECHANISM.SELF_MANAGED: {
const filePath = path.normalize(path.join(srcPath, fileName));
if (fs.existsSync(filePath)) {
containerDefinitionFiles[fileName] = fs.readFileSync(filePath).toString();
}
break;
}
case DEPLOYMENT_MECHANISM.INDENPENDENTLY_MANAGED: {
const { path: repoUri, tokenSecretArn } = gitHubInfo;
const { SecretString: gitHubToken } = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'retrieveSecret', {
secretArn: tokenSecretArn,
});
const octokit = new Octokit({ auth: gitHubToken });
const { owner, repo, branch, path: pathInRepo } = getGitHubOwnerRepoFromPath(repoUri);
try {
const {
data: { content, encoding },
} = (await octokit.repos.getContent({
owner,
repo,
...(branch ? { ref: branch } : undefined), // only include branch if not undefined
path: path.join(pathInRepo, fileName),
})) as { data: { content?: string; encoding?: string } };
containerDefinitionFiles[fileName] = Buffer.from(content, <BufferEncoding>encoding).toString('utf8');
} catch (error) {
const { status } = error;
// It is ok if the file doesn't exist, we skip it
if (status !== 404) {
throw error;
}
}
break;
}
default: {
const exhaustiveCheck: never = deploymentMechanism;
throw new Error(`Unhandled type [${exhaustiveCheck}]`);
}
}
}
if (Object.keys(containerDefinitionFiles).length === 0) {
throw new Error('No definition available (docker-compose.yaml / docker-compose.yml / Dockerfile)');
}
if (containerDefinitionFiles[dockerComposeFileNameYaml] && containerDefinitionFiles[dockerComposeFileNameYml]) {
throw new Error('There should be only one docker-compose.yaml / docker-compose.yml)');
}
const composeContents = containerDefinitionFiles[dockerComposeFileNameYaml] || containerDefinitionFiles[dockerComposeFileNameYml];
const { [dockerfileFileName]: dockerfileContents } = containerDefinitionFiles;
const { buildspec, containers, service, secrets } = getContainers(composeContents, dockerfileContents);
const containersPorts = containers.reduce(
(acc, container) => acc.concat(container.portMappings.map(({ containerPort }) => containerPort)),
<number[]>[],
);
const newContainersName = Array.from(new Set(containers.map(({ name }) => name)));
let isInitialDeploy = Object.keys(output ?? {}).length === 0;
const currentContainersSet = new Set(output?.ContainerNames?.split(','));
// Service require all containers to exists
isInitialDeploy = isInitialDeploy || newContainersName.some((newContainer) => !currentContainersSet.has(newContainer));
let exposedContainer: { name: string; port: number };
const containersExposed = containers.filter((container) => container.portMappings.length > 0);
if (containersPorts.length === 0) {
throw new Error('Service requires at least one exposed port');
} else if (containersPorts.length > 1) {
exposedContainer = await checkContainerExposed(containersExposed, exposedContainerFromMeta, askForExposedContainer);
} else {
exposedContainer = {
name: containersExposed[0].name,
port: containersExposed[0].portMappings[0].containerPort,
};
}
fs.ensureDirSync(srcPath);
fs.writeFileSync(path.join(srcPath, 'buildspec.yml'), buildspec);
const secretsArns: Map<string, string> = new Map<string, string>();
if ((await shouldUpdateSecrets(context, secrets)) || isInitialDeploy) {
// Normalizes paths
// Validate secrets file paths, existence and prefixes
const errors = Object.entries(secrets).reduce((acc, [secretName, secretFilePath]) => {
const baseDir = path.isAbsolute(secretFilePath) ? '' : srcPath;
const normalizedFilePath = path.normalize(path.join(baseDir, secretFilePath));
secrets[secretName] = normalizedFilePath;
let canRead = true;
try {
const fd = fs.openSync(normalizedFilePath, 'r');
fs.closeSync(fd);
} catch (err) {
canRead = false;
}
if (!canRead) {
acc.push(`Secret file "${secretFilePath}" can't be read.`);
return acc;
}
const basename = path.basename(normalizedFilePath);
const hasCorrectPrefix = basename.startsWith('.secret-');
if (!hasCorrectPrefix) {
acc.push(`Secret file "${secretFilePath}" doesn't start with the ".secret-" prefix.`);
return acc;
}
const isInsideSrc = normalizedFilePath.startsWith(path.join(srcPath, path.sep));
if (isInsideSrc) {
acc.push(`Secret file "${secretFilePath}" should not be inside the "src" folder. The "src" folder will be uploaded to S3.`);
return acc;
}
return acc;
}, <string[]>[]);
if (errors.length > 0) {
throw new Error(['Error(s) in secret file(s):'].concat(errors).join('\n'));
}
for await (const entries of Object.entries(secrets)) {
const [secretName, secretFilePath] = entries;
const contents = fs.readFileSync(secretFilePath).toString();
const ssmSecretName = `${envName}-${resourceName}-${secretName}`;
const { ARN: secretArn } = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'upsertSecretValue', {
secret: contents,
description: `Secret for ${resourceName}`,
name: ssmSecretName,
version: uuid(),
});
const [prefix] = secretArn.toString().split(ssmSecretName);
const secretArnRef = cdk.Fn.join('', [prefix, cdk.Fn.ref('rootStackName'), '-', resourceName, '-', secretName]);
secretsArns.set(secretName, secretArnRef);
}
} else {
const { cfnTemplate } = readCFNTemplate(path.join(pathManager.getBackendDirPath(), category, resourceName, cfnFileName(resourceName)));
setExistingSecretArns(secretsArns, cfnTemplate);
}
const desiredCount = service?.replicas ?? 1; // TODO: 1 should be from meta (HA setting)
return {
containersPorts,
containers,
isInitialDeploy,
desiredCount,
exposedContainer,
secretsArns,
};
}
async function shouldUpdateSecrets(context: $TSContext, secrets: Record<string, string>): Promise<boolean> {
const hasSecrets = Object.keys(secrets).length > 0;
if (!hasSecrets || context.exeInfo.inputParams.yes) {
return false;
}
const { update_secrets } = await inquirer.prompt({
name: 'update_secrets',
type: 'confirm',
message: 'Secret configuration detected. Do you wish to store new values in the cloud?',
default: false,
});
return update_secrets;
}
async function checkContainerExposed(
containersExposed: Container[],
exposedContainerFromMeta: { name: string; port: number } = { name: '', port: 0 },
askForExposedContainer: boolean = false,
): Promise<{ name: string; port: number }> {
const containerExposed = containersExposed.find((container) => container.name === exposedContainerFromMeta.name);
if (!askForExposedContainer && containerExposed?.portMappings.find((port) => port.containerPort === exposedContainerFromMeta.port)) {
return { ...exposedContainerFromMeta };
} else {
const choices: { name: string; value: Container }[] = containersExposed.map((container) => ({
name: container.name,
value: container,
}));
const { containerToExpose } = await inquirer.prompt({
message: 'Select which container is the entrypoint',
name: 'containerToExpose',
type: 'list',
choices,
});
return {
name: containerToExpose.name,
port: containerToExpose.portMappings[0].containerPort,
};
}
}