Skip to content

Commit

Permalink
feat(tmdt): support endpoint-url parameter and respect S3 bucket name…
Browse files Browse the repository at this point in the history
… character limit
  • Loading branch information
hwandersman committed Apr 26, 2024
1 parent 86c92ce commit b4ea8fb
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 71 deletions.
8 changes: 8 additions & 0 deletions packages/tools-iottwinmaker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ ___
- *--workspace-id*: Specify the ID of the Workspace to bootstrap the project from
- *--out*: Specify the directory to initialize a project in

### Init Parameters (optional flags)
- *--endpoint-url*: Specify the TM service endpoint url

Use this command to bootstrap a TMDT project from an existing workspace.

The following will initialize a tmdt project at the specified directory with a `tmdt.json` file
Expand All @@ -78,6 +81,10 @@ ___
- *--workspace-id*: Specify the ID of the Workspace to deploy to
- *--dir*: Specify the project location, directory for tmdt.json file

### Deploy Parameters (optional flags)
- *--endpoint-url*: Specify the TM service endpoint url
- *--execution-role*: Specify the name of the execution role to associate with a new workspace

The following will deploy a tmdt project at the specified directory (the directory must contain a `tmdt.json` file) into the specified workspace.

```
Expand All @@ -104,6 +111,7 @@ TMDT destroy is a destructive command hence it is a "Dry Run" by default command
- *--delete-workspace*: Specify if TM workspace should also be deleted
- *--delete-s3-bucket*: Specify if workspace s3 Bucket, its contents, and any associated logging bucket should be deleted
- *--nonDryRun*: Use this flag for real deletion of resources
- *--endpoint-url*: Specify the TM service endpoint url

**Example 1:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ test('basic functional test', async () => {
$0: 'tmdt_local',
region: 'us-east-1',
'workspace-id': constants.workspaceId,
'non-dry-run': true,
} as Arguments<destroy.Options>;
expect(await destroy.handler(argv2)).toBe(0);

Expand Down
134 changes: 95 additions & 39 deletions packages/tools-iottwinmaker/src/commands/deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,45 @@ const iamMock = mockClient(IAMClient);
const stsMock = mockClient(STSClient);
const fakeTmdtDir = '/tmp/deploy-unit-tests';

const emptyProjectSpy = () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockImplementation((path) => {
if (typeof path !== 'string') throw new Error('Not a string');
if (path.includes('tmdt')) {
return JSON.stringify(emptyTmdt, null, 4);
} else {
// covers entities.json
return JSON.stringify([], null, 4);
}
});
};

const mockCallsForWorkspaceDoesNotExist = () => {
stsMock
.on(GetCallerIdentityCommand)
.resolves({ Account: 'fakeAccountId', Arn: 'fakeAccountArn' });
twinmakerMock
.on(GetWorkspaceCommand)
.rejectsOnce(new ResourceNotFoundException({ $metadata: {}, message: '' }))
.rejectsOnce(new ResourceNotFoundException({ $metadata: {}, message: '' }))
.resolves({ workspaceId: 'fakeId' });
s3Mock.on(CreateBucketCommand).resolves({ Location: 'fakeLocation' });
s3Mock.on(PutBucketVersioningCommand).resolves({});
s3Mock.on(PutBucketPolicyCommand).resolves({});
s3Mock.on(PutPublicAccessBlockCommand).resolves({});
s3Mock.on(PutBucketEncryptionCommand).resolves({});
s3Mock.on(PutBucketAclCommand).resolves({});
s3Mock.on(PutBucketCorsCommand).resolves({});
twinmakerMock.on(CreateWorkspaceCommand).resolves({ arn: '*' });
iamMock.on(CreateRoleCommand).resolves({ Role: fakeRole });
iamMock.on(CreatePolicyCommand).resolves({ Policy: fakePolicy });
iamMock.on(AttachRolePolicyCommand).resolves({});
};

beforeEach(() => {
twinmakerMock.reset();
s3Mock.reset();
iamMock.reset();
jest.resetAllMocks();
});

Expand All @@ -86,17 +122,28 @@ it('throws error when given tmdt project that does not exist', async () => {
);
});

it('throws error when user provided execution role does not exist', async () => {
emptyProjectSpy();

prompts.inject(['Y']);

mockCallsForWorkspaceDoesNotExist();
const error = new NoSuchEntityException({ $metadata: {}, message: '' });
iamMock.on(GetRoleCommand).rejects(error);

const argv2 = {
_: ['init'],
$0: 'tmdt_local',
region: 'us-east-1',
'workspace-id': 'non-existent',
dir: fakeTmdtDir,
'execution-role': 'role-name-does-not-exist',
} as Arguments<Options>;
await expect(handler(argv2)).rejects.toThrow(error);
});

it('deploys nothing when given an empty tmdt project', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockImplementation((path) => {
if (typeof path !== 'string') throw new Error('Not a string');
if (path.includes('tmdt')) {
return JSON.stringify(emptyTmdt, null, 4);
} else {
// covers entities.json
return JSON.stringify([], null, 4);
}
});
emptyProjectSpy();
twinmakerMock.on(GetWorkspaceCommand).resolves({});

const argv2 = {
Expand All @@ -113,39 +160,14 @@ it('deploys nothing when given an empty tmdt project', async () => {
});

it('creates new workspace when given workspace that does not exist and user prompts to create new one', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockImplementation((path) => {
if (typeof path !== 'string') throw new Error('Not a string');
if (path.includes('tmdt')) {
return JSON.stringify(emptyTmdt, null, 4);
} else {
// covers entities.json
return JSON.stringify([], null, 4);
}
});
emptyProjectSpy();

prompts.inject(['Y']);
stsMock
.on(GetCallerIdentityCommand)
.resolves({ Account: 'fakeAccountId', Arn: 'fakeAccountArn' });
twinmakerMock
.on(GetWorkspaceCommand)
.rejectsOnce(new ResourceNotFoundException({ $metadata: {}, message: '' }))
.rejectsOnce(new ResourceNotFoundException({ $metadata: {}, message: '' }))
.resolves({ workspaceId: 'fakeId' });
s3Mock.on(CreateBucketCommand).resolves({ Location: 'fakeLocation' });
s3Mock.on(PutBucketVersioningCommand).resolves({});
s3Mock.on(PutBucketPolicyCommand).resolves({});
s3Mock.on(PutPublicAccessBlockCommand).resolves({});
s3Mock.on(PutBucketEncryptionCommand).resolves({});
s3Mock.on(PutBucketAclCommand).resolves({});
s3Mock.on(PutBucketCorsCommand).resolves({});
twinmakerMock.on(CreateWorkspaceCommand).resolves({ arn: '*' });

mockCallsForWorkspaceDoesNotExist();
iamMock
.on(GetRoleCommand)
.rejects(new NoSuchEntityException({ $metadata: {}, message: '' }));
iamMock.on(CreateRoleCommand).resolves({ Role: fakeRole });
iamMock.on(CreatePolicyCommand).resolves({ Policy: fakePolicy });
iamMock.on(AttachRolePolicyCommand).resolves({});

const argv2 = {
_: ['init'],
Expand All @@ -170,6 +192,40 @@ it('creates new workspace when given workspace that does not exist and user prom
expect(iamMock.commandCalls(AttachRolePolicyCommand).length).toBe(2);
});

it('creates new workspace when given workspace that does not exist and a user provided execution role', async () => {
emptyProjectSpy();

prompts.inject(['Y']);

mockCallsForWorkspaceDoesNotExist();
iamMock
.on(GetRoleCommand)
.resolvesOnce({
Role: {
Path: 'fakePath',
RoleName: 'fakeRoleName',
RoleId: 'fakeRoleId',
Arn: 'fakeRoleArn',
CreateDate: new Date(),
},
})
.rejects(new NoSuchEntityException({ $metadata: {}, message: '' }));

const argv2 = {
_: ['init'],
$0: 'tmdt_local',
region: 'us-east-1',
'workspace-id': 'non-existent',
dir: fakeTmdtDir,
'execution-role': 'fakeRoleName',
} as Arguments<Options>;
expect(await handler(argv2)).toBe(0);
expect(iamMock.commandCalls(GetRoleCommand).length).toBe(2);
expect(iamMock.commandCalls(CreateRoleCommand).length).toBe(1);
expect(iamMock.commandCalls(CreatePolicyCommand).length).toBe(1);
expect(iamMock.commandCalls(AttachRolePolicyCommand).length).toBe(1);
});

it('deploys successfully when given a tmdt project with one component type', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockImplementation((path) => {
Expand Down
22 changes: 20 additions & 2 deletions packages/tools-iottwinmaker/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export type Options = {
region: string;
'workspace-id': string;
dir: string;
'endpoint-url': string;
'execution-role': string;
};

export const command = 'deploy';
Expand All @@ -51,17 +53,33 @@ export const builder: CommandBuilder<Options> = (yargs) =>
require: true,
description: 'Specify the project location, directory for tmdt.json file',
},
'endpoint-url': {
type: 'string',
require: false,
description: 'Specify the AWS IoT TwinMaker endpoint.',
default: '',
},
'execution-role': {
type: 'string',
require: false,
description:
'The name of the execution role associated with the workspace.',
default: '',
},
});

export const handler = async (argv: Arguments<Options>) => {
const workspaceId: string = argv['workspace-id']; // TODO allow it to be optional (i.e. option to autogenerate workspace for them)
const region: string = argv.region;
const dir: string = argv.dir;
const tmEndpoint: string = argv['endpoint-url'];
const executionRole: string = argv['execution-role'];

console.log(
`Deploying project from directory ${dir} into workspace ${workspaceId} in ${region}`
);

initDefaultAwsClients({ region: region });
initDefaultAwsClients({ region, tmEndpoint });
if (!fs.existsSync(path.join(dir, 'tmdt.json'))) {
throw new Error('TDMK.json does not exist. Please run tmdt init first.');
}
Expand All @@ -85,7 +103,7 @@ export const handler = async (argv: Arguments<Options>) => {
workspace role to continue deployment (Y)? Press any other key to abort (n).`,
});
if (response.confirmation === 'Y') {
await createWorkspaceIfNotExists(workspaceId);
await createWorkspaceIfNotExists(workspaceId, executionRole);
} else {
console.log('Aborting deployment...');
return 0;
Expand Down
10 changes: 9 additions & 1 deletion packages/tools-iottwinmaker/src/commands/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type Options = {
'delete-workspace': boolean;
'delete-s3-bucket': boolean;
'non-dry-run': boolean;
'endpoint-url': string;
};

export const command = 'destroy';
Expand Down Expand Up @@ -56,6 +57,12 @@ export const builder: CommandBuilder<Options> = (yargs) =>
description: 'Specify non-dry-run for real run execution of destroy.',
default: false,
},
'endpoint-url': {
type: 'string',
require: false,
description: 'Specify the AWS IoT TwinMaker endpoint.',
default: '',
},
});

export const handler = async (argv: Arguments<Options>) => {
Expand All @@ -64,8 +71,9 @@ export const handler = async (argv: Arguments<Options>) => {
let deleteWorkspaceFlag: boolean = argv['delete-workspace'];
let deleteS3Flag: boolean = argv['delete-s3-bucket'];
const nonDryRun: boolean = argv['non-dry-run'];
const tmEndpoint: string = argv['endpoint-url'];

initDefaultAwsClients({ region: region });
initDefaultAwsClients({ region, tmEndpoint });

await verifyWorkspaceExists(workspaceId);

Expand Down
10 changes: 9 additions & 1 deletion packages/tools-iottwinmaker/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type Options = {
region: string;
'workspace-id': string;
out: string;
'endpoint-url': string;
};

export type tmdt_config_file = {
Expand Down Expand Up @@ -63,6 +64,12 @@ export const builder: CommandBuilder<Options> = (yargs) =>
require: true,
description: 'Specify the directory to initialize a project in.',
},
'endpoint-url': {
type: 'string',
require: false,
description: 'Specify the AWS IoT TwinMaker endpoint.',
default: '',
},
});

async function import_component_types(
Expand Down Expand Up @@ -417,11 +424,12 @@ export const handler = async (argv: Arguments<Options>) => {
const workspaceId: string = argv['workspace-id'];
const region: string = argv.region;
const outDir: string = argv.out;
const tmEndpoint: string = argv['endpoint-url'];
console.log(
`Bootstrapping project from workspace ${workspaceId} in ${region} at project directory ${outDir}`
);

initDefaultAwsClients({ region: region });
initDefaultAwsClients({ region, tmEndpoint });

await verifyWorkspaceExists(workspaceId);

Expand Down
14 changes: 10 additions & 4 deletions packages/tools-iottwinmaker/src/lib/aws-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ class AwsClients {
cf: CloudFormation;
kvs: KinesisVideo;

constructor(region: string) {
constructor(region: string, tmEndpoint?: string) {
this.region = region;
const options = { customUserAgent: 'tmdt/0.0.2', region: region };
this.sts = new STS(options);
this.tm = new IoTTwinMaker(options);
// Only pass an endpoint if there is a string with content
const endpoint =
tmEndpoint === undefined || tmEndpoint === '' ? undefined : tmEndpoint;
this.tm = new IoTTwinMaker({ ...options, endpoint });
this.iam = new IAM(options);
this.s3 = new S3(options);
this.cf = new CloudFormation(options);
Expand All @@ -45,8 +48,11 @@ let defaultAwsClients: AwsClients | null = null;
* Helper function that create new aws client with a given region
* @param options object containing the aws region
*/
function initDefaultAwsClients(options: { region: string }) {
defaultAwsClients = new AwsClients(options.region);
function initDefaultAwsClients(options: {
region: string;
tmEndpoint?: string;
}) {
defaultAwsClients = new AwsClients(options.region, options.tmEndpoint);
}

/**
Expand Down

0 comments on commit b4ea8fb

Please sign in to comment.