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: version blocking for CLI #8512

Merged
merged 2 commits into from
Oct 21, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1,038 changes: 583 additions & 455 deletions .circleci/config.yml

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions packages/amplify-cli-core/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,10 @@ export class NotInitializedError extends Error {
public constructor() {
super();
this.name = 'NotInitializedError';
this.message = `
No Amplify backend project files detected within this folder. Either initialize a new Amplify project or pull an existing project.
- "amplify init" to initialize a new Amplify project
- "amplify pull <app-id>" to pull your existing Amplify project. Find the <app-id> in the AWS Console or Amplify Admin UI.
`;
this.message = `No Amplify backend project files detected within this folder. Either initialize a new Amplify project or pull an existing project.
- "amplify init" to initialize a new Amplify project
- "amplify pull <app-id>" to pull your existing Amplify project. Find the <app-id> in the AWS Console or Amplify Admin UI.`;

this.stack = undefined;
}
}
8 changes: 7 additions & 1 deletion packages/amplify-cli-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export * from './cliRemoveResourcePrompt';
export * from './cliViewAPI';
export * from './hooks';
export * from './cliViewAPI';
export * from './customPoliciesUtils'
export * from './customPoliciesUtils';

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

Expand All @@ -51,6 +51,7 @@ export type $TSContext = {
newUserInfo?: $TSAny;
filesystem: IContextFilesystem;
template: IContextTemplate;
versionInfo: CLIVersionInfo;
};

export type CategoryName = string;
Expand Down Expand Up @@ -145,6 +146,11 @@ export type DeploymentSecrets = {
}>;
};

export type CLIVersionInfo = {
currentCLIVersion: string;
minimumCompatibleCLIVersion: string;
};

/**
* Plugins or other packages bundled with the CLI that pass a file to a system command or execute a binary file must export a function named
* "getPackageAssetPaths" of this type.
Expand Down
5 changes: 5 additions & 0 deletions packages/amplify-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
"engines": {
"node": ">=12.0.0"
},
"amplify-cli": {
"configuration": {
"minimumCompatibleCLIVersion": "6.4.0"
}
},
"dependencies": {
"@aws-cdk/cloudformation-diff": "~1.124.0",
"amplify-app": "3.0.16",
Expand Down
11 changes: 10 additions & 1 deletion packages/amplify-cli/src/__tests__/context-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ jest.mock('../domain/amplify-usageData/', () => {
});
jest.mock('../app-config');

jest.mock('../version-gating', () => ({
getCurrentCLIVersion: jest.fn().mockReturnValue(() => '5.2.0'),
getMinimumCompatibleCLIVersion: jest.fn().mockReturnValue(() => '5.0.0'),
}));

describe('test attachUsageData', () => {
const version = 'latestversion';
const version = '5.2.0';
const mockContext = jest.genMockFromModule<Context>('../domain/context');

mockContext.input = new Input([
'/Users/userName/.nvm/versions/node/v8.11.4/bin/node',
'/Users/userName/.nvm/versions/node/v8.11.4/bin/amplify',
'status',
]);
mockContext.versionInfo = {
currentCLIVersion: '5.2.0',
minimumCompatibleCLIVersion: '5.0.0',
};
mockContext.pluginPlatform = new PluginPlatform();
mockContext.pluginPlatform.plugins['core'] = [new PluginInfo('', version, '', new PluginManifest('', ''))];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { constructMockPluginPlatform } from './mock-plugin-platform';

import { getAllCategoryPluginInfo } from '../../../extensions/amplify-helpers/get-all-category-pluginInfos';

jest.mock('../../../version-gating');

test('getAllCategoryPluginInfo', () => {
const mockPluginPlatform = constructMockPluginPlatform();
const mockProcessArgv = [
Expand All @@ -17,7 +19,7 @@ test('getAllCategoryPluginInfo', () => {
const mockInput = new Input(mockProcessArgv);
const mockContext = constructContext(mockPluginPlatform, mockInput);

const categoryPluginInfoList = (getAllCategoryPluginInfo(mockContext) as unknown) as PluginCollection;
const categoryPluginInfoList = getAllCategoryPluginInfo(mockContext) as unknown as PluginCollection;
expect(categoryPluginInfoList.hosting).toBeDefined();
expect(Object.keys(categoryPluginInfoList.hosting).length).toEqual(2);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { constructMockPluginPlatform } from './mock-plugin-platform';
import { constructContext } from '../../../context-manager';
import { getCategoryPluginInfo } from '../../../extensions/amplify-helpers/get-category-pluginInfo';

jest.mock('../../../version-gating');

test('getCategoryPluginInfo returns the first pluginInfo to match category', () => {
const mockPluginPlatform = constructMockPluginPlatform();
const mockProcessArgv = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ describe('getProjectMeta', () => {
});
it('should throw NotInitializedError when metaFile does not exists', () => {
stateManager_mock.metaFileExists.mockImplementation(() => false);
expect(() => getProjectMeta()).toThrow(`
No Amplify backend project files detected within this folder. Either initialize a new Amplify project or pull an existing project.
- "amplify init" to initialize a new Amplify project
- "amplify pull <app-id>" to pull your existing Amplify project. Find the <app-id> in the AWS Console or Amplify Admin UI.
`);
expect(() => getProjectMeta()).toThrow(
`No Amplify backend project files detected within this folder. Either initialize a new Amplify project or pull an existing project.
- "amplify init" to initialize a new Amplify project
- "amplify pull <app-id>" to pull your existing Amplify project. Find the <app-id> in the AWS Console or Amplify Admin UI.`,
);
});
});
266 changes: 266 additions & 0 deletions packages/amplify-cli/src/__tests__/version-gating.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { $TSContext } from 'amplify-cli-core';

describe('command blocking', () => {
test('validate which commands will be blocked or not', async () => {
const { isCommandInMatches, versionGatingBlockedCommands } = await import('../version-gating');

expect(isCommandInMatches({ plugin: 'api', command: 'add' }, versionGatingBlockedCommands)).toBe(true);
expect(isCommandInMatches({ plugin: 'function', command: 'add' }, versionGatingBlockedCommands)).toBe(true);

expect(isCommandInMatches({ plugin: 'api', command: 'update' }, versionGatingBlockedCommands)).toBe(true);
expect(isCommandInMatches({ plugin: 'function', command: 'update' }, versionGatingBlockedCommands)).toBe(true);

expect(isCommandInMatches({ plugin: 'api', command: 'remove' }, versionGatingBlockedCommands)).toBe(true);
expect(isCommandInMatches({ plugin: 'function', command: 'remove' }, versionGatingBlockedCommands)).toBe(true);

expect(isCommandInMatches({ plugin: 'core', command: 'push' }, versionGatingBlockedCommands)).toBe(true);
expect(isCommandInMatches({ plugin: 'api', command: 'push' }, versionGatingBlockedCommands)).toBe(true);
expect(isCommandInMatches({ plugin: 'function', command: 'push' }, versionGatingBlockedCommands)).toBe(true);

expect(isCommandInMatches({ plugin: 'hosting', command: 'publish' }, versionGatingBlockedCommands)).toBe(true);

expect(isCommandInMatches({ plugin: 'api', command: 'gql-compile' }, versionGatingBlockedCommands)).toBe(true);

expect(isCommandInMatches({ plugin: undefined, command: 'help' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'version' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'configure' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'console' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'init' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'logout' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'status' }, versionGatingBlockedCommands)).toBe(false);
expect(isCommandInMatches({ plugin: undefined, command: 'pull' }, versionGatingBlockedCommands)).toBe(false);

expect(isCommandInMatches({ plugin: 'env', command: 'list' }, versionGatingBlockedCommands)).toBe(false);
});
});

describe('version gating', () => {
const originalProcessEnv = process.env;

let stackMetadata: any = undefined;

class CfnClientMock {
public getTemplateSummary = () => {
return {
promise: () =>
new Promise((resolve, _) => {
resolve({ Metadata: stackMetadata });
}),
};
};
}

const cfnClientMockInstance = new CfnClientMock();

class CloudFormation {
cfn: CfnClientMock;

constructor() {
this.cfn = cfnClientMockInstance;
}
}

const cloudFormationClient_stub = new CloudFormation();

const meta_stub = {
providers: {
awscloudformation: {
StackName: 'mockstack',
},
},
};

const stackMetadata_stub_520_500 = {
AmplifyCLI: {
DeployedByCLIVersion: '5.2.0',
MinimumCompatibleCLIVersion: '5.0.0',
},
};

const stackMetadata_stub_520_530 = {
AmplifyCLI: {
DeployedByCLIVersion: '5.2.0',
MinimumCompatibleCLIVersion: '5.3.0',
},
};

const stackMetadata_stub_530_531 = {
AmplifyCLI: {
DeployedByCLIVersion: '5.3.0',
MinimumCompatibleCLIVersion: '5.3.1',
},
};

const versionInfo_520_500 = {
currentCLIVersion: '5.2.0',
minimumCompatibleCLIVersion: '5.0.0',
};

const versionInfo_520_510 = {
currentCLIVersion: '5.2.0',
minimumCompatibleCLIVersion: '5.1.0',
};

const versionInfo_520_540 = {
currentCLIVersion: '5.2.0',
minimumCompatibleCLIVersion: '5.4.0',
};

const versionInfo_532_530 = {
currentCLIVersion: '5.3.2',
minimumCompatibleCLIVersion: '5.3.0',
};

const context_stub = {
print: {
info: jest.fn(),
warning: jest.fn(),
success: jest.fn(),
},
input: {
plugin: 'api',
command: 'add',
},
versionInfo: versionInfo_520_500,
amplify: {
invokePluginMethod: jest.fn().mockReturnValue(cloudFormationClient_stub),
},
} as unknown as jest.Mocked<$TSContext>;

beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();

// reset mutated state
context_stub.input.plugin = 'api';
context_stub.input.command = 'add';
context_stub.versionInfo = versionInfo_520_500;

stackMetadata = undefined;

process.env = { ...originalProcessEnv };
});

afterEach(() => {
jest.clearAllMocks();
jest.resetModules();

// reset mutated state
context_stub.input.plugin = 'api';
context_stub.input.command = 'add';

stackMetadata = undefined;

process.env = { ...originalProcessEnv };
});

test('version gating should pass when env override set', async () => {
process.env.AMPLIFY_CLI_DISABLE_VERSION_CHECK = '1';

const versionGating = await import('../version-gating');

const isCommandInMatchesMock = jest.spyOn(versionGating, 'isCommandInMatches');

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);

expect(isCommandInMatchesMock).toHaveBeenCalledTimes(0);
});

test('version gating should pass when command is non-blocking', async () => {
context_stub.input.plugin = 'core';
context_stub.input.command = 'version';

const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

const isCommandInMatchesMock = jest.spyOn(versionGating, 'isCommandInMatches');
const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => undefined);

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);

expect(isCommandInMatchesMock).toHaveBeenCalledTimes(1);
expect(stateManagerMock).toHaveBeenCalledTimes(0);
});

test('version gating should pass when stack is not deployed', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => undefined);

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);

expect(stateManagerMock).toHaveBeenCalledTimes(1);
expect(context_stub.amplify.invokePluginMethod).toHaveBeenCalledTimes(0);
});

test('version gating should pass when stack has no metadata', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => meta_stub);

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);

expect(stateManagerMock).toHaveBeenCalledTimes(1);
expect(context_stub.amplify.invokePluginMethod).toHaveBeenCalledTimes(1);
});

test('version gating should pass, meta: 5.2.0, metamin: 5.0.0, current: 5.2.0, min: 5.0.0', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

stackMetadata = stackMetadata_stub_520_500;

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => meta_stub);

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);
});

test('version gating should pass, meta: 5.2.0, metamin: 5.0.0, current: 5.2.0, min: 5.1.0', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

stackMetadata = stackMetadata_stub_520_500;
context_stub.versionInfo = versionInfo_520_510;

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => meta_stub);

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);
});

test('version gating should pass, meta: 5.3.0, metamin: 5.3.1, current: 5.3.2, min: 5.3.0', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

stackMetadata = stackMetadata_stub_530_531;
context_stub.versionInfo = versionInfo_532_530;

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => meta_stub);

await expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toBe(true);
});

test('version gating should fail, meta: 5.2.0, metamin: 5.3.0, current: 5.2.0, min: 5.0.0', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

stackMetadata = stackMetadata_stub_520_530;

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => meta_stub);

expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toEqual(false);
});

test('version gating should fail, meta: 5.2.0, metamin: 5.3.0, current: 5.2.0, min: 5.4.0', async () => {
const versionGating = await import('../version-gating');
const { stateManager } = await import('amplify-cli-core');

stackMetadata = stackMetadata_stub_520_530;
context_stub.versionInfo = versionInfo_520_540;

const stateManagerMock = jest.spyOn(stateManager, 'getMeta').mockImplementation(() => meta_stub);

expect(versionGating.isMinimumVersionSatisfied(context_stub)).resolves.toEqual(false);
});
});