@govuk-pay/cli 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@govuk-pay/cli",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "GOV.UK Pay Command Line Interface",
5
5
  "bin": {
6
6
  "pay": "bin/cli.js",
@@ -11,6 +11,10 @@
11
11
  "author": "",
12
12
  "license": "MIT",
13
13
  "dependencies": {
14
+ "@aws-sdk/client-ec2": "^3.641.0",
15
+ "@aws-sdk/client-ecs": "^3.637.0",
16
+ "@aws-sdk/client-rds": "^3.637.0",
17
+ "@aws-sdk/client-ssm": "^3.651.1",
14
18
  "openurl": "^1.1.1",
15
19
  "ts-standard": "^12.0.2"
16
20
  },
@@ -8,4 +8,5 @@ Commands:
8
8
  pay schema # Generates web based database diagrams and me...
9
9
  pay secrets # Manage secrets in and between environments
10
10
  pay ssm # Start an SSM session to boxes in environments
11
+ pay tunnel # Open tunnel to application database
11
12
  pay tf # Runs Terraform
@@ -0,0 +1,392 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.logTunnelCommands = void 0;
7
+ const standardContent_js_1 = require("../core/standardContent.js");
8
+ const client_ec2_1 = require("@aws-sdk/client-ec2");
9
+ const client_ecs_1 = require("@aws-sdk/client-ecs");
10
+ const client_rds_1 = require("@aws-sdk/client-rds");
11
+ const client_ssm_1 = require("@aws-sdk/client-ssm");
12
+ const readline_1 = __importDefault(require("readline"));
13
+ const child_process_1 = require("child_process");
14
+ const constants_js_1 = require("../core/constants.js");
15
+ let ec2;
16
+ let ecs;
17
+ let ssm;
18
+ const FORMAT = {
19
+ red: '\x1b[31m',
20
+ green: '\x1b[32m',
21
+ yellow: '\x1b[33m',
22
+ reset: '\x1b[0m',
23
+ ul: '\x1b[4m',
24
+ ulstop: '\x1b[24m'
25
+ };
26
+ function logTunnelCommands() {
27
+ console.log(`Commands:
28
+ pay tunnel <ENVIRONMENT> <APP-NAME> # Open tunnel to application <APP-NAME> database in specified environment <ENVIRONMENT>
29
+ pay tunnel help # Describe tunnel command`);
30
+ }
31
+ exports.logTunnelCommands = logTunnelCommands;
32
+ async function tunnelHandler(options) {
33
+ await (0, standardContent_js_1.showHeader)();
34
+ const { environment, application } = parseArguments(options);
35
+ console.log(`Opening a database tunnel to ${environment} ${application}`);
36
+ ec2 = new client_ec2_1.EC2Client();
37
+ ecs = new client_ecs_1.ECSClient();
38
+ ssm = new client_ssm_1.SSMClient();
39
+ let bastionTask = null;
40
+ try {
41
+ printWarningToUser();
42
+ const database = await getDatabaseDetails(environment, application);
43
+ bastionTask = await startBastion(environment);
44
+ openTunnel(bastionTask, database, environment);
45
+ printHowToTunnelText(application, environment, database.EngineVersion);
46
+ await waitForExit();
47
+ await shutdown(environment, bastionTask);
48
+ }
49
+ catch (error) {
50
+ if (typeof error === 'string') {
51
+ printError(error);
52
+ }
53
+ else if (error instanceof Error) {
54
+ printError(error.message);
55
+ }
56
+ await shutdown(environment, bastionTask);
57
+ process.exit(2);
58
+ }
59
+ }
60
+ exports.default = tunnelHandler;
61
+ function parseArguments(options) {
62
+ if (options.arguments.length !== 2 || options.arguments[0] === 'help') {
63
+ logTunnelCommands();
64
+ process.exit(0);
65
+ }
66
+ const environment = options.arguments[0];
67
+ const application = options.arguments[1];
68
+ if (!constants_js_1.APPLICATIONS.includes(application)) {
69
+ printError(`Invalid application: "${application}". Must be one of ${constants_js_1.APPLICATIONS.join(', ')}`);
70
+ process.exit(2);
71
+ }
72
+ if (!constants_js_1.ENVIRONMENTS.includes(environment)) {
73
+ printError(`Invalid environment: "${environment}". Must be one of ${constants_js_1.ENVIRONMENTS.join(', ')}`);
74
+ process.exit(2);
75
+ }
76
+ return {
77
+ environment, application
78
+ };
79
+ }
80
+ async function waitForExit() {
81
+ const prompt = "\nTo shutdown bastion task and close tunnel type 'exit' or 'Ctrl-C'\n";
82
+ const rl = readline_1.default.createInterface({
83
+ input: process.stdin,
84
+ output: process.stdout
85
+ });
86
+ printGreen(prompt);
87
+ return await new Promise(resolve => {
88
+ rl.on('line', input => {
89
+ if (input === 'exit') {
90
+ rl.close();
91
+ resolve();
92
+ }
93
+ else {
94
+ console.log(`Unknown command ${input}`);
95
+ printGreen(prompt);
96
+ }
97
+ });
98
+ rl.on('SIGINT', () => {
99
+ console.log('Received: Ctrl-C');
100
+ rl.close();
101
+ resolve();
102
+ });
103
+ });
104
+ }
105
+ async function startBastion(environment) {
106
+ const subnetIds = await getBastionSubNets(environment);
107
+ const securityGroupIds = await getBastionSecurityGroups(environment);
108
+ let bastionTask = await launchBastionTask(environment, subnetIds, securityGroupIds);
109
+ bastionTask = await waitForBastionToBeReady(bastionTask, environment);
110
+ return bastionTask;
111
+ }
112
+ async function getBastionSubNets(environment) {
113
+ const subnetCommand = new client_ec2_1.DescribeSubnetsCommand({
114
+ Filters: [{
115
+ Name: 'tag:Name',
116
+ Values: [`${environment}-bastion`]
117
+ }]
118
+ });
119
+ const subnetCommandResponse = await ec2.send(subnetCommand);
120
+ const subnets = subnetCommandResponse.Subnets;
121
+ if (subnets == null || subnets.length < 1) {
122
+ throw new Error(`Failed to find subnets for the bastion in ${environment}`);
123
+ }
124
+ return subnets
125
+ .filter(s => s.SubnetId !== undefined)
126
+ .map((subnet) => subnet.SubnetId);
127
+ }
128
+ async function getBastionSecurityGroups(environment) {
129
+ const securityGroupCommand = new client_ec2_1.DescribeSecurityGroupsCommand({
130
+ Filters: [{
131
+ Name: 'group-name',
132
+ Values: [`${environment}-bastion`]
133
+ }]
134
+ });
135
+ const securityGroupCommandResponse = await ec2.send(securityGroupCommand);
136
+ const securityGroups = securityGroupCommandResponse.SecurityGroups;
137
+ if (securityGroups == null || securityGroups.length < 1) {
138
+ throw new Error(`Failed to find security groups for the bastion in ${environment}`);
139
+ }
140
+ return securityGroups
141
+ .filter(s => s.GroupId !== undefined)
142
+ .map((securityGroup) => securityGroup.GroupId);
143
+ }
144
+ async function launchBastionTask(environment, subnetIds, securityGroupIds) {
145
+ const runTaskCommand = new client_ecs_1.RunTaskCommand({
146
+ cluster: `${environment}-fargate`,
147
+ taskDefinition: `${environment}-bastion`,
148
+ launchType: 'FARGATE',
149
+ enableExecuteCommand: true,
150
+ networkConfiguration: {
151
+ awsvpcConfiguration: {
152
+ subnets: subnetIds,
153
+ securityGroups: securityGroupIds,
154
+ assignPublicIp: 'DISABLED'
155
+ }
156
+ },
157
+ // TODO: Add the developer's name as a Tag or suffix to startedBy.
158
+ startedBy: 'PAY-CLI'
159
+ });
160
+ const runTaskCommandResponse = await ecs.send(runTaskCommand);
161
+ if ((runTaskCommandResponse.failures != null) && runTaskCommandResponse.failures.length > 0) {
162
+ const failureReasons = runTaskCommandResponse.failures
163
+ .map((failure) => failure.reason)
164
+ .join(', ');
165
+ throw new Error(`Failed to run bastion task: ${failureReasons}`);
166
+ }
167
+ else if (runTaskCommandResponse.tasks?.length !== 1) {
168
+ throw new Error(`Expected 1 bastion task but got: ${String(runTaskCommandResponse.tasks?.length)}`);
169
+ }
170
+ else {
171
+ return runTaskCommandResponse.tasks[0];
172
+ }
173
+ }
174
+ async function refreshTask(taskArn, environment) {
175
+ const describeTaskCommand = new client_ecs_1.DescribeTasksCommand({
176
+ cluster: `${environment}-fargate`,
177
+ tasks: [taskArn]
178
+ });
179
+ const describeTaskCommandResponse = await ecs.send(describeTaskCommand);
180
+ if ((describeTaskCommandResponse.failures != null) && describeTaskCommandResponse.failures.length > 0) {
181
+ const failureReasons = describeTaskCommandResponse.failures
182
+ .map((failure) => failure.reason)
183
+ .join(', ');
184
+ throw new Error(`Failed to get task: ${failureReasons}`);
185
+ }
186
+ else if (describeTaskCommandResponse.tasks?.length !== 1) {
187
+ throw new Error(`Expected 1 bastion task but got: ${String(describeTaskCommandResponse.tasks?.length)}`);
188
+ }
189
+ else {
190
+ return describeTaskCommandResponse.tasks[0];
191
+ }
192
+ }
193
+ async function sleep(ms) {
194
+ return await new Promise(resolve => setTimeout(resolve, ms));
195
+ }
196
+ async function waitForBastionToBeReady(task, environment) {
197
+ console.log('Waiting for the bastion task to start');
198
+ let previousStatus;
199
+ while (!isRunningAndConnected(task)) {
200
+ if (task.lastStatus !== previousStatus) {
201
+ previousStatus = task.lastStatus;
202
+ process.stdout.write(`\n\tCurrent status: ${task.lastStatus} `);
203
+ if (task.lastStatus === 'RUNNING') {
204
+ process.stdout.write('\n\tWaiting for the bastion to connect to the network ');
205
+ }
206
+ }
207
+ else {
208
+ process.stdout.write('.');
209
+ }
210
+ await sleep(1000);
211
+ task = await refreshTask(task.taskArn, environment);
212
+ if (task.desiredStatus !== 'RUNNING') {
213
+ console.error(`\tBastion desired state is unexpectedly: ${task.desiredStatus}`);
214
+ throw Error('Bastion task failed to start');
215
+ }
216
+ }
217
+ console.log('\n\tBastion started successfully');
218
+ return task;
219
+ }
220
+ function isRunningAndConnected(task) {
221
+ // RUNNING doesn't mean that the task is ready to accept network connections.
222
+ // The 'ExecuteCommandAgent' must be running within the container before ssm
223
+ // can connect. Note: task.connectivity === 'CONNECTED' is not sufficient.
224
+ const connectionStatus = task.attachments
225
+ ?.filter(a => a.type === 'ElasticNetworkInterface')
226
+ .map(a => a.status)[0];
227
+ const agentStatus = task.containers
228
+ ?.flatMap(c => c.managedAgents)
229
+ .filter(agent => agent?.name === 'ExecuteCommandAgent')
230
+ .map(agent => agent?.lastStatus)[0];
231
+ return task.lastStatus === 'RUNNING' && connectionStatus === 'ATTACHED' && agentStatus === 'RUNNING';
232
+ }
233
+ async function stopBastion(task, environment) {
234
+ const stopTaskCommand = new client_ecs_1.StopTaskCommand({
235
+ task: task.taskArn,
236
+ cluster: `${environment}-fargate`,
237
+ reason: 'Stopping bastion'
238
+ });
239
+ const stopTaskCommandResponse = await ecs.send(stopTaskCommand);
240
+ if (stopTaskCommandResponse.task == null) {
241
+ throw new Error(`Failed to stop bastion task: ${task.taskArn}`);
242
+ }
243
+ console.log('Stopping Bastion');
244
+ }
245
+ async function getDatabaseDetails(environment, application) {
246
+ const describeDbCommand = new client_rds_1.DescribeDBInstancesCommand({});
247
+ const rds = new client_rds_1.RDSClient();
248
+ const describeDbCommandResponse = await rds.send(describeDbCommand);
249
+ if (describeDbCommandResponse.DBInstances == null || describeDbCommandResponse.DBInstances.length < 1) {
250
+ throw new Error(`Failed to find the database for ${application} in ${environment}`);
251
+ }
252
+ const appDatabases = describeDbCommandResponse.DBInstances
253
+ .filter(s => s.DBInstanceIdentifier?.startsWith(`${environment}-${application}`));
254
+ if (appDatabases.length === 0) {
255
+ throw new Error(`Failed to find the database for ${application} in ${environment}`);
256
+ }
257
+ else if (appDatabases.length > 1) {
258
+ // TODO: Allow an argument to specify the exact database name. Print out how
259
+ // to re-run the describeDbCommand specifying the required database.
260
+ const databaseNames = appDatabases.map(d => d.DBInstanceIdentifier).join(':');
261
+ throw new Error(`There are multiple matching databases: ${databaseNames}`);
262
+ }
263
+ else {
264
+ return appDatabases[0];
265
+ }
266
+ }
267
+ function openTunnel(task, db, environment) {
268
+ const cluster = `${environment}-fargate`;
269
+ const ecsTaskId = task.taskArn?.split('/').at(-1);
270
+ const ecsContainerRunTimeId = task.containers?.map(c => c.runtimeId)[0];
271
+ const rdsEndpoint = db.Endpoint?.Address;
272
+ const target = `ecs:${cluster}_${ecsTaskId}_${ecsContainerRunTimeId}`;
273
+ const parameters = `{"host":["${rdsEndpoint}"],"portNumber":["5432"],"localPortNumber":["65432"]}`;
274
+ const commandArgs = [
275
+ 'ssm',
276
+ 'start-session',
277
+ '--target', target,
278
+ '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
279
+ '--parameters', parameters
280
+ ];
281
+ const tunnelProc = (0, child_process_1.spawn)('aws', commandArgs, { detached: true });
282
+ // TODO: useful for testing when the spawned process exits itself. Consider
283
+ // removing before final launch of the command.
284
+ // const tunnelProc = spawn('sleep', ['5'], { detached: true })
285
+ tunnelProc.stdout.on('data', (data) => {
286
+ console.log(`tunnel command: ${data}`);
287
+ });
288
+ tunnelProc.stderr.on('data', (data) => {
289
+ console.error(`tunnel command error: ${data}`);
290
+ });
291
+ tunnelProc.on('close', (code, _signal) => {
292
+ if (code !== null && code !== 0) {
293
+ console.error(`\nTunnel unexpectedly closed with exit code: ${code}`);
294
+ console.group();
295
+ console.error('Type `exit` or `Ctrl-C` to shutdown the bastion task then try again.');
296
+ const tunnelCommandForConsole = commandArgs
297
+ .join(' ')
298
+ .replaceAll('--', '\\\n--')
299
+ .replace('{', "'{")
300
+ .replace('}', "}'");
301
+ console.error(`The command to manually create the tunnel is: \naws ${tunnelCommandForConsole}`);
302
+ console.groupEnd();
303
+ }
304
+ else {
305
+ console.log('Tunnel closed');
306
+ }
307
+ });
308
+ return tunnelProc;
309
+ }
310
+ async function shutdown(environment, bastionTask) {
311
+ console.log('Shutting down');
312
+ if (bastionTask != null) {
313
+ await terminateSession(bastionTask, environment);
314
+ }
315
+ if (bastionTask !== null) {
316
+ bastionTask = await refreshTask(bastionTask.taskArn, environment);
317
+ if (bastionTask.desiredStatus !== 'STOPPED') {
318
+ await stopBastion(bastionTask, environment);
319
+ }
320
+ }
321
+ printGreen('Shutdown complete.');
322
+ }
323
+ async function terminateSession(task, environment) {
324
+ const cluster = `${environment}-fargate`;
325
+ const ecsTaskId = task.taskArn?.split('/').at(-1);
326
+ const ecsContainerRunTimeId = task.containers?.map(c => c.runtimeId)[0];
327
+ const target = `ecs:${cluster}_${ecsTaskId}_${ecsContainerRunTimeId}`;
328
+ const describeSessionsCommand = new client_ssm_1.DescribeSessionsCommand({
329
+ State: 'Active',
330
+ Filters: [
331
+ {
332
+ key: 'Target',
333
+ value: target
334
+ }
335
+ ]
336
+ });
337
+ const describeSessionsOutput = await ssm.send(describeSessionsCommand);
338
+ const sessions = describeSessionsOutput.Sessions;
339
+ if (sessions != null) {
340
+ for (const session of sessions) {
341
+ const terminateSessionCommand = new client_ssm_1.TerminateSessionCommand({
342
+ SessionId: session.SessionId
343
+ });
344
+ await ssm.send(terminateSessionCommand);
345
+ }
346
+ }
347
+ }
348
+ function printHowToTunnelText(application, environment, dbEngineVersion) {
349
+ const dbUser = getDbUser(application);
350
+ const payLowPassDbSecretName = getPayLowPassDbSecretname(environment, dbUser);
351
+ const paySecretsPasswordName = getPaySecretsPasswordName();
352
+ printGreen(`\nConnected tunnel to ${application} RDS database in ${environment} on port 65432\n`);
353
+ printGreen('Copy DB credentials to clipboard (in another window) using pay-low-pass:');
354
+ printGreen(` pay-low-pass ${payLowPassDbSecretName} | pbcopy`);
355
+ printGreen('Alternatively, fetch credentials from pay secrets:');
356
+ printGreen(` pay secrets fetch ${environment} ${application} ${paySecretsPasswordName} | pbcopy`);
357
+ printGreen('Open psql with:');
358
+ printGreen(` psql -h localhost -p 65432 -U ${dbUser} -d ${application}`);
359
+ printGreen('Alternatively connect using docker instead of needing psql installed locally and set the password automatically using pay-low-pass:');
360
+ printGreen(` docker run --rm -ti postgres:${dbEngineVersion}-alpine psql --host docker.for.mac.localhost --port 65432 --user ${dbUser} --dbname ${application}`);
361
+ printGreen('Or even more conveniently connect using a docker container and set the password automatically using pay-low-pass:');
362
+ printGreen(` docker run -e "PGPASSWORD=$(pay-low-pass ${payLowPassDbSecretName})" --rm -ti postgres:${dbEngineVersion}-alpine psql --host docker.for.mac.localhost --port 65432 --user ${dbUser} --dbname ${application}\n`);
363
+ }
364
+ // TODO: add a write flag. Default to readonly.
365
+ function getDbUser(application) {
366
+ return `${application}_support_readonly`;
367
+ }
368
+ function getPayLowPassDbSecretname(environment, user) {
369
+ return `aws/rds/support_readonly_users/${environment.split('-')[0]}/${user}`;
370
+ }
371
+ function getPaySecretsPasswordName() {
372
+ return 'DB_SUPPORT_PASSWORD_READONLY';
373
+ }
374
+ function printWarningToUser() {
375
+ console.log(FORMAT.yellow, '⚠️ WARNING: When using SSM, any and all activity you perform may be getting logged for security auditing purposes (think PCI).', FORMAT.reset);
376
+ console.log(FORMAT.yellow, `Avoid sending or accessing ${FORMAT.ul}anything${FORMAT.ulstop} that could cause a security breach, such as:`, FORMAT.reset);
377
+ console.log(FORMAT.yellow, FORMAT.reset);
378
+ console.log(FORMAT.yellow, '• Secret API Keys or Tokens', FORMAT.reset);
379
+ console.log(FORMAT.yellow, '• Credentials or Passwords', FORMAT.reset);
380
+ console.log(FORMAT.yellow, '• Cardholder Data or Personally-Identifiable Information (PII)', FORMAT.reset);
381
+ console.log(FORMAT.yellow, '• Anything else that may be protected by GDPR or PCI-DSS', FORMAT.reset);
382
+ console.log(FORMAT.yellow, "• Anything classified as GSC 'Secret' or above", FORMAT.reset);
383
+ console.log(FORMAT.yellow, FORMAT.reset);
384
+ console.log(FORMAT.yellow, `If you have a problem with this or aren't sure, use Ctrl-C ${FORMAT.ul}right now${FORMAT.ulstop} and discontinue your SSM session.`, FORMAT.reset);
385
+ console.log(FORMAT.yellow, FORMAT.reset);
386
+ }
387
+ function printError(error) {
388
+ console.error(`${FORMAT.red}${error}${FORMAT.reset}`);
389
+ }
390
+ function printGreen(message) {
391
+ console.error(`${FORMAT.green}${message}${FORMAT.reset}`);
392
+ }
@@ -8,10 +8,14 @@ const browse_js_1 = __importDefault(require("../commands/browse.js"));
8
8
  const demo_js_1 = __importDefault(require("../commands/demo.js"));
9
9
  const legacy_1 = __importDefault(require("../commands/legacy"));
10
10
  const help_1 = __importDefault(require("../commands/help"));
11
+ const tunnel_js_1 = __importDefault(require("../commands/tunnel.js"));
11
12
  const handlers = new Map();
12
13
  handlers.set('browse', {
13
14
  handler: browse_js_1.default
14
15
  });
16
+ handlers.set('tunnel', {
17
+ handler: tunnel_js_1.default
18
+ });
15
19
  handlers.set('legacy', {
16
20
  handler: legacy_1.default
17
21
  });
@@ -23,11 +23,29 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.distDirForBuildTasks = exports.rootDirForBuildTasks = exports.rootDir = void 0;
26
+ exports.APPLICATIONS = exports.ENVIRONMENTS = exports.distDirForBuildTasks = exports.rootDirForBuildTasks = exports.rootDir = void 0;
27
27
  const path = __importStar(require("path"));
28
28
  exports.rootDir = path.resolve(__dirname, '..', '..');
29
29
  exports.rootDirForBuildTasks = exports.rootDir.endsWith('dist') ? path.resolve(exports.rootDir, '..') : exports.rootDir;
30
30
  exports.distDirForBuildTasks = path.join(exports.rootDirForBuildTasks, 'dist');
31
+ exports.ENVIRONMENTS = ['test-12', 'test-perf-1', 'staging-2', 'deploy-tooling', 'production-2'];
32
+ exports.APPLICATIONS = [
33
+ 'adminusers',
34
+ 'cardid',
35
+ 'connector',
36
+ 'egress',
37
+ 'frontend',
38
+ 'ledger',
39
+ 'notifications',
40
+ 'pact-broker',
41
+ 'products',
42
+ 'products',
43
+ 'publicapi',
44
+ 'publicauth',
45
+ 'selfservice',
46
+ 'toolbox',
47
+ 'webhooks'
48
+ ];
31
49
  exports.default = {
32
50
  rootDir: exports.rootDir,
33
51
  distDirForBuildTasks: exports.distDirForBuildTasks,