Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: resource Tagging Support #5178

Merged
merged 3 commits into from
Aug 27, 2020
Merged
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
261 changes: 137 additions & 124 deletions .circleci/config.yml

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions packages/amplify-cli-core/src/__tests__/tags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { validate } from '../tags/Tags';

describe('tags-validation:', () => {
describe('case: tags-validation receives a JSON file with duplicate keys', () => {
const json = [
{ Key: 'user:Stack', Value: 'dev' },
{ Key: 'user:Application', Value: 'foobar' },
{ Key: 'user:Application', Value: 'foobar' },
];

it('tags-validation should throw an error saying that the tags.json file contians duplicate keys', () => {
expect(() => validate(json)).toThrowError(new Error("'Key' should be unique"));
});
});

describe('case: tags-validation receives a JSON file that contains more than 50 key-value pairs', () => {
const json = [
{ Key: 'user:Stack', Value: 'dev', key: 'notgood' },
{ Key: 'user:Application', Value: 'foobar' },
{ Key: 'user:Application', Value: 'foobar' },
];

it('tags-validation should throw an error stating that the tags.json file has exceeded the tags amount limit', () => {
expect(() => validate(json)).toThrowError(new Error('Tag thould be of type Key: string, Value: string'));
});
});

describe('case: tags-validation receives a JSON file that contains more than 50 key-value pairs', () => {
const jsonObjects: any = [];

for (let i = 0; i < 55; i++) {
jsonObjects.push({
Key: `user:key${i}`,
Value: `value${i}`,
});
}

it('tags-validation should throw an error stating that the tags.json file has exceeded the tags amount limit', () => {
expect(() => validate(jsonObjects)).toThrowError(new Error('No. of tags cannot exceed 50'));
});
});
});
1 change: 1 addition & 0 deletions packages/amplify-cli-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './feature-flags';
export * from './jsonUtilities';
export * from './jsonValidationError';
export * from './state-manager';
export * from './tags';

// Temporary types until we can finish full type definition across the whole CLI

Expand Down
7 changes: 7 additions & 0 deletions packages/amplify-cli-core/src/state-manager/pathManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const PathConstants = {
GitIgnoreFileName: '.gitignore',
ProjectConfigFileName: 'project-config.json',
AmplifyMetaFileName: 'amplify-meta.json',
TagsFileName: 'tags.json',

LocalEnvFileName: 'local-env-info.json',
LocalAWSInfoFileName: 'local-aws-info.json',
Expand Down Expand Up @@ -68,6 +69,12 @@ export class PathManager {
getBackendConfigFilePath = (projectPath?: string): string =>
this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.BackendDirName, PathConstants.BackendConfigFileName]);

getTagFilePath = (projectPath?: string): string =>
this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.BackendDirName, PathConstants.TagsFileName]);

getCurrentTagFilePath = (projectPath?: string): string =>
this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.CurrentCloudBackendDirName, PathConstants.TagsFileName]);

getCurrentAmplifyMetaFilePath = (projectPath?: string): string =>
this.constructPath(projectPath, [
PathConstants.AmplifyDirName,
Expand Down
10 changes: 10 additions & 0 deletions packages/amplify-cli-core/src/state-manager/stateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fs from 'fs-extra';
import { pathManager } from './pathManager';
import { $TSMeta, $TSTeamProviderInfo, $TSAny } from '..';
import { JSONUtilities } from '../jsonUtilities';
import { Tag, ReadValidateTags } from '../tags';

export type GetOptions<T> = {
throwIfNotExist?: boolean;
Expand Down Expand Up @@ -37,6 +38,10 @@ export class StateManager {
return data;
};

getProjectTags = (projectPath?: string): Tag[] => ReadValidateTags(pathManager.getTagFilePath(projectPath));

getCurrentProjectTags = (projectPath?: string): Tag[] => ReadValidateTags(pathManager.getCurrentTagFilePath(projectPath));

teamProviderInfoExists = (projectPath?: string): boolean => fs.existsSync(pathManager.getTeamProviderInfoFilePath(projectPath));

getTeamProviderInfo = (projectPath?: string, options?: GetOptions<$TSTeamProviderInfo>): $TSTeamProviderInfo => {
Expand Down Expand Up @@ -105,6 +110,11 @@ export class StateManager {
JSONUtilities.writeJson(filePath, localAWSInfo);
};

setProjectFileTags = (projectPath: string | undefined, tags: Tag[]): void => {
const tagFilePath = pathManager.getTagFilePath(projectPath);
JSONUtilities.writeJson(tagFilePath, tags);
};

setProjectConfig = (projectPath: string | undefined, projectConfig: $TSAny): void => {
const filePath = pathManager.getProjectConfigFilePath(projectPath);

Expand Down
35 changes: 35 additions & 0 deletions packages/amplify-cli-core/src/tags/Tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { JSONUtilities } from '../jsonUtilities';
import _ from 'lodash';

export interface Tag {
Key: string;
Value: String;
}

export function ReadValidateTags(tagsFilePath: string): Tag[] {
const tags = JSONUtilities.readJson<Tag[]>(tagsFilePath, {
throwIfNotExist: false,
preserveComments: false,
});

if (!tags) return [];

validate(tags);

return tags;
}

export function validate(tags: Tag[]): void {
const allowedKeySet = new Set(['Key', 'Value']);

//check if Tags have the right format
_.each(tags, tags => {
if (_.some(Object.keys(tags), r => !allowedKeySet.has(r))) throw new Error('Tag thould be of type Key: string, Value: string');
});

//check if Tag Key is repeated
if (_.uniq(tags.map(r => r.Key)).length !== tags.length) throw new Error("'Key' should be unique");

//check If tags exceed limit
if (tags.length > 50) throw new Error('No. of tags cannot exceed 50');
}
1 change: 1 addition & 0 deletions packages/amplify-cli-core/src/tags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Tags';
5 changes: 5 additions & 0 deletions packages/amplify-cli/src/domain/amplify-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export class AmplifyToolkit {
private _deleteAllTriggers: any;
private _deleteDeselectedTriggers: any;
private _dependsOnBlock: any;
private _getTags: any;
private _getTriggerMetadata: any;
private _getTriggerPermissions: any;
private _getTriggerEnvVariables: any;
Expand Down Expand Up @@ -370,6 +371,10 @@ export class AmplifyToolkit {
this._dependsOnBlock = this._dependsOnBlock || require(path.join(this._amplifyHelpersDirPath, 'trigger-flow')).dependsOnBlock;
return this._dependsOnBlock;
}
get getTags(): any {
this._getTags = this._getTags || require(path.join(this._amplifyHelpersDirPath, 'get-tags')).getTags;
return this._getTags;
}
get getTriggerMetadata(): any {
this._getTriggerMetadata =
this._getTriggerMetadata || require(path.join(this._amplifyHelpersDirPath, 'trigger-flow')).getTriggerMetadata;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const amplifyCLIConstants = {
LocalEnvFileName: 'local-env-info.json',
ProviderInfoFileName: 'team-provider-info.json',
BackendConfigFileName: 'backend-config.json',
TagsFileName: 'tags.json',
PROJECT_CONFIG_VERSION: '3.0',
BreadcrumbsFileName: 'amplify.state',
};
21 changes: 21 additions & 0 deletions packages/amplify-cli/src/extensions/amplify-helpers/get-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { stateManager } from 'amplify-cli-core';
import { getProjectDetails } from './get-project-details';
export function getTags() {
const projectDetails = getProjectDetails();
const { projectName } = projectDetails.projectConfig;
const { envName } = projectDetails.localEnvInfo;
return HydrateTags(stateManager.getProjectTags(), projectName, envName);
}

function HydrateTags(tags: any[], envName: string, projectName: string) {
const replace = {
'{project-name}': projectName,
'{project-env}': envName,
};
return tags.map(tag => {
return {
...tag,
Value: tag.Value.replace(/{project-name}|{project-env}/g, (matched: string) => replace[matched]),
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { hashElement } from 'folder-hash';
import { getEnvInfo } from './get-env-info';
import { CLOUD_INITIALIZED, CLOUD_NOT_INITIALIZED, getCloudInitStatus } from './get-cloud-init-status';
import { ServiceName as FunctionServiceName, hashLayerResource } from 'amplify-category-function';
import { pathManager, stateManager, $TSMeta, $TSAny } from 'amplify-cli-core';
import { pathManager, stateManager, $TSMeta, $TSAny, Tag } from 'amplify-cli-core';

async function isBackendDirModifiedSinceLastPush(resourceName, category, lastPushTimeStamp, isLambdaLayer = false) {
// Pushing the resource for the first time hence no lastPushTimeStamp
Expand Down Expand Up @@ -251,15 +251,31 @@ export async function getResourceStatus(category?, resourceName?, providerName?,
resourcesToBeDeleted = resourcesToBeDeleted.filter(resource => resource.providerPlugin === providerName);
allResources = allResources.filter(resource => resource.providerPlugin === providerName);
}

const tagsUpdated = compareTags(stateManager.getProjectTags(), stateManager.getCurrentProjectTags());
return {
resourcesToBeCreated,
resourcesToBeUpdated,
resourcesToBeDeleted,
tagsUpdated,
allResources,
};
}

function compareTags(tags: Tag[], currenTags: Tag[]): boolean {
if (tags.length !== currenTags.length) return true;
const tagMap = new Map(tags.map(tag => [tag.Key, tag.Value]));
if (
_.some(currenTags, tag => {
if (tagMap.has(tag.Key)) {
if (tagMap.get(tag.Key) === tag.Value) return false;
}
})
)
return true;

return false;
}

export async function showResourceTable(category, resourceName, filteredResources) {
const amplifyProjectInitStatus = getCloudInitStatus();
if (amplifyProjectInitStatus === CLOUD_INITIALIZED) {
Expand All @@ -270,7 +286,7 @@ export async function showResourceTable(category, resourceName, filteredResource
print.info('');
}

const { resourcesToBeCreated, resourcesToBeUpdated, resourcesToBeDeleted, allResources } = await getResourceStatus(
const { resourcesToBeCreated, resourcesToBeUpdated, resourcesToBeDeleted, allResources, tagsUpdated } = await getResourceStatus(
category,
resourceName,
undefined,
Expand Down Expand Up @@ -321,7 +337,10 @@ export async function showResourceTable(category, resourceName, filteredResource
const { table } = print;

table(tableOptions, { format: 'markdown' });
if (tagsUpdated) {
print.info('Resource Tags Update Detected');
}
const resourceChanged = resourcesToBeCreated.length + resourcesToBeUpdated.length + resourcesToBeDeleted.length > 0 || tagsUpdated;

const changedResourceCount = resourcesToBeCreated.length + resourcesToBeUpdated.length + resourcesToBeDeleted.length;
return changedResourceCount;
return resourceChanged;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ function moveBackendResourcesToCurrentCloudBackend(resources) {
const amplifyCloudMetaFilePath = pathManager.getCurrentAmplifyMetaFilePath();
const backendConfigFilePath = pathManager.getBackendConfigFilePath();
const backendConfigCloudFilePath = pathManager.getCurrentBackendConfigFilePath();
const tagFilePath = pathManager.getTagFilePath();
const tagCloudFilePath = pathManager.getCurrentTagFilePath();

for (let i = 0; i < resources.length; i += 1) {
const sourceDir = path.normalize(path.join(pathManager.getBackendDirPath(), resources[i].category, resources[i].resourceName));
Expand All @@ -56,6 +58,7 @@ function moveBackendResourcesToCurrentCloudBackend(resources) {

fs.copySync(amplifyMetaFilePath, amplifyCloudMetaFilePath, { overwrite: true });
fs.copySync(backendConfigFilePath, backendConfigCloudFilePath, { overwrite: true });
fs.copySync(tagFilePath, tagCloudFilePath, { overwrite: true });
}

export function updateamplifyMetaAfterResourceAdd(category, resourceName, options: { dependsOn? } = {}) {
Expand Down
20 changes: 19 additions & 1 deletion packages/amplify-cli/src/init-steps/s9-onSuccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export async function onSuccess(context: $TSContext) {
function generateLocalRuntimeFiles(context: $TSContext) {
generateLocalEnvInfoFile(context);
generateAmplifyMetaFile(context);
generateLocalTagsFile(context);
}

export function generateLocalEnvInfoFile(context: $TSContext) {
Expand All @@ -74,7 +75,24 @@ export function generateLocalEnvInfoFile(context: $TSContext) {
stateManager.setLocalEnvInfo(projectPath, context.exeInfo.localEnvInfo);
}

function generateAmplifyMetaFile(context: $TSContext) {
function generateLocalTagsFile(context: $TSContext) {
if (context.exeInfo.isNewProject) {
const { projectPath } = context.exeInfo.localEnvInfo;
const tags = [
{
Key: 'user:Stack',
Value: '{project-env}',
},
{
Key: 'user:Application',
Value: '{project-name}',
},
];
stateManager.setProjectFileTags(projectPath, tags);
}
}

export function generateAmplifyMetaFile(context: $TSContext) {
if (context.exeInfo.isNewEnv) {
const { projectPath } = context.exeInfo.localEnvInfo;

Expand Down
6 changes: 6 additions & 0 deletions packages/amplify-e2e-core/src/utils/projectMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ function getProjectMeta(projectRoot: string) {
return JSON.parse(fs.readFileSync(metaFilePath, 'utf8'));
}

function getProjectTags(projectRoot: string) {
const metaFilePath = path.join(projectRoot, 'amplify', '#current-cloud-backend', 'tags.json');
return JSON.parse(fs.readFileSync(metaFilePath, 'utf8'));
jhockett marked this conversation as resolved.
Show resolved Hide resolved
}

function getBackendAmplifyMeta(projectRoot: string) {
const metaFilePath = path.join(projectRoot, 'amplify', 'backend', 'amplify-meta.json');
return JSON.parse(fs.readFileSync(metaFilePath, 'utf8'));
Expand All @@ -51,6 +56,7 @@ function getAwsIOSConfig(projectRoot: string) {

export {
getProjectMeta,
getProjectTags,
getBackendAmplifyMeta,
getAwsAndroidConfig,
getAwsIOSConfig,
Expand Down
49 changes: 49 additions & 0 deletions packages/amplify-e2e-tests/src/__tests__/tags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
initJSProjectWithProfile,
createNewProjectDir,
deleteProject,
deleteProjectDir,
amplifyPushWithoutCodegen,
getProjectMeta,
getProjectTags,
describeCloudFormationStack,
addDEVHosting,
} from 'amplify-e2e-core';

describe('generated tags test', () => {
let projRoot: string;

beforeEach(async () => {
projRoot = await createNewProjectDir('tags');
});

afterEach(async () => {
await deleteProject(projRoot);
deleteProjectDir(projRoot);
});

it('should compare the nested stack tags key with the tags.json file and return true', async () => {
await initJSProjectWithProfile(projRoot, {});
await addDEVHosting(projRoot);
await amplifyPushWithoutCodegen(projRoot);

// This block of code gets the necessary info to compare the values of both the local tags from the JSON file and tags on the stack
const amplifyMeta = getProjectMeta(projRoot);
const meta = amplifyMeta.providers.awscloudformation;
const rootStackInfo = await describeCloudFormationStack(meta.StackName, meta.Region);
const localTags = getProjectTags(projRoot);

// Currently only checks to make sure that thhe pushed tags have the same amount and name of keys than the ones added locally on the tags.json file
expect(checkEquality(localTags, rootStackInfo.Tags)).toBe(true);
});
});

// ? Not sure if this is the best way to indicate an array of objects in TS
function checkEquality(localTags: {}[], generatedTags: {}[]) {
localTags.forEach(tagObj => {
const rootTag = generatedTags.find(obj => obj['Key'] === tagObj['Key']);
if (tagObj['Key'] !== rootTag['Key']) return false;
});

return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ class CloudFormation {
const authRoleName = projectDetails.amplifyMeta.providers ? projectDetails.amplifyMeta.providers[providerName].AuthRoleName : '';
const unauthRoleName = projectDetails.amplifyMeta.providers ? projectDetails.amplifyMeta.providers[providerName].UnauthRoleName : '';

const Tags = this.context.amplify.getTags();

if (!stackName) {
throw new Error('Project stack has not been created yet. Use amplify init to initialize the project.');
}
Expand All @@ -221,7 +223,6 @@ class CloudFormation {
const cfnModel = this.cfn;
const { context } = this;
const self = this;

this.eventStartTime = new Date();
return new Promise((resolve, reject) => {
this.describeStack(cfnStackCheckParams)
Expand All @@ -244,6 +245,7 @@ class CloudFormation {
ParameterValue: unauthRoleName,
},
],
Tags,
};

cfnModel.updateStack(cfnParentStackParams, updateErr => {
Expand Down