Skip to content

Commit

Permalink
feat: version blocking for CLI (#8512)
Browse files Browse the repository at this point in the history
* feat: version blocking for CLI

* chore: run split-tests
  • Loading branch information
Attila Hajdrik committed Oct 21, 2021
1 parent 02e750b commit 52edf2b
Show file tree
Hide file tree
Showing 22 changed files with 1,403 additions and 494 deletions.
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
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
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
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
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
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);
});
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
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
@@ -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);
});
});

0 comments on commit 52edf2b

Please sign in to comment.