@agilecustoms/envctl 0.11.0 → 0.13.0

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.
@@ -19,6 +19,7 @@ export class TerraformAdapter {
19
19
  console.log(`\nTime EST: ${est}, UTC: ${utc}\n`);
20
20
  }
21
21
  async init(key, cwd) {
22
+ console.log('Run terraform init to download providers, this doesn\'t create any resources in AWS even S3 object');
22
23
  let emptyDir = false;
23
24
  const scanner = (line) => {
24
25
  if (line.includes('Terraform initialized in an empty directory!')) {
@@ -40,22 +41,40 @@ export class TerraformAdapter {
40
41
  throw new KnownException('Can not find terraform files. Command needs to be run in a directory with terraform files');
41
42
  }
42
43
  }
43
- tfArgs(envAttrs, tfArgs) {
44
+ tfArgs(envDto, tfArgs) {
44
45
  return [
45
- `-var=env=${envAttrs.env}`,
46
- `-var=owner=${envAttrs.owner}`,
47
- `-var=env_size=${envAttrs.size}`,
48
- `-var=env_type=${envAttrs.type}`,
46
+ `-var=env=${envDto.env}`,
47
+ `-var=owner=${envDto.owner}`,
48
+ `-var=env_size=${envDto.size}`,
49
+ `-var=env_type=${envDto.type}`,
49
50
  ...tfArgs
50
51
  ];
51
52
  }
52
- async plan(envAttrs, tfArgs, cwd) {
53
- const args = this.tfArgs(envAttrs, tfArgs);
53
+ async plan(envDto, tfArgs, cwd, attemptNo = 1) {
54
+ const args = this.tfArgs(envDto, tfArgs);
54
55
  console.log('Running: terraform plan -auto-approve', ...args);
55
- await this.processRunner.run('terraform', ['plan', ...args], cwd);
56
+ try {
57
+ await this.processRunner.run('terraform', ['plan', ...args], cwd);
58
+ }
59
+ catch (error) {
60
+ if (error instanceof ProcessException) {
61
+ if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
62
+ console.warn(`Retrying terraform plan due to error: ${error.message}`);
63
+ return this.plan(envDto, tfArgs, cwd, attemptNo + 1);
64
+ }
65
+ const lockId = this.lockId(attemptNo, error);
66
+ if (lockId) {
67
+ await this.promptUnlock(lockId, cwd);
68
+ console.info('State unlocked, retrying terraform plan');
69
+ return this.plan(envDto, tfArgs, cwd, attemptNo + 1);
70
+ }
71
+ throw new KnownException(`terraform plan failed with code ${error.code}:\n${error.message}`, { cause: error });
72
+ }
73
+ throw error;
74
+ }
56
75
  }
57
- async deploy(envAttrs, tfArgs, cwd, attemptNo = 1) {
58
- const args = this.tfArgs(envAttrs, tfArgs);
76
+ async deploy(envDto, tfArgs, cwd, attemptNo = 1) {
77
+ const args = this.tfArgs(envDto, tfArgs);
59
78
  console.log('Running: terraform apply -auto-approve', ...args);
60
79
  this.printTime();
61
80
  try {
@@ -66,30 +85,36 @@ export class TerraformAdapter {
66
85
  if (error instanceof ProcessException) {
67
86
  if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
68
87
  console.warn(`Retrying terraform apply due to error: ${error.message}`);
69
- return this.deploy(envAttrs, tfArgs, cwd, attemptNo + 1);
88
+ return this.deploy(envDto, tfArgs, cwd, attemptNo + 1);
70
89
  }
71
- if (attemptNo < MAX_ATTEMPTS && error.message.includes('Error acquiring the state lock')) {
72
- const match = error.message.match(/ID:\s+([a-f0-9-]{36})/i);
73
- if (match) {
74
- console.error(error.message);
75
- const answerYes = await promptYesNo('Terraform state is locked. Most often due to previously interrupted process.\n'
76
- + 'Rarely (main lock intention) is when another process actively work with the state to avoid inconsistent modification.\n'
77
- + 'Do you want to force unlock? (y/n): ');
78
- if (answerYes) {
79
- console.log('Force unlocking state');
80
- const id = match[1];
81
- await this.processRunner.run('terraform', ['force-unlock', '-force', id], cwd);
82
- console.info('State unlocked, retrying terraform apply');
83
- return this.deploy(envAttrs, tfArgs, cwd, attemptNo + 1);
84
- }
85
- else {
86
- throw new ExitException();
87
- }
88
- }
90
+ const lockId = this.lockId(attemptNo, error);
91
+ if (lockId) {
92
+ await this.promptUnlock(lockId, cwd);
93
+ console.info('State unlocked, retrying terraform apply');
94
+ return this.deploy(envDto, tfArgs, cwd, attemptNo + 1);
89
95
  }
90
96
  throw new KnownException(`terraform apply failed with code ${error.code}:\n${error.message}`, { cause: error });
91
97
  }
92
98
  throw error;
93
99
  }
94
100
  }
101
+ lockId(attemptNo, error) {
102
+ if (attemptNo < MAX_ATTEMPTS && error.message.includes('Error acquiring the state lock')) {
103
+ const match = error.message.match(/ID:\s+([a-f0-9-]{36})/i);
104
+ if (match) {
105
+ console.error(error.message);
106
+ return match[1];
107
+ }
108
+ }
109
+ }
110
+ async promptUnlock(id, cwd) {
111
+ const answerYes = await promptYesNo('Terraform state is locked. Most often due to previously interrupted process.\n'
112
+ + 'Rarely (main lock intention) is when another process actively work with the state to avoid inconsistent modification.\n'
113
+ + 'Do you want to force unlock?: ');
114
+ if (!answerYes) {
115
+ throw new ExitException();
116
+ }
117
+ console.log('Force unlocking state');
118
+ await this.processRunner.run('terraform', ['force-unlock', '-force', id], cwd);
119
+ }
95
120
  }
@@ -1,26 +1,18 @@
1
1
  import { Command } from 'commander';
2
- import { configService, envCtl } from '../container.js';
3
- import { KnownException } from '../exceptions.js';
2
+ import { envCtl } from '../container.js';
3
+ import { KEYS } from './keys.js';
4
4
  import { wrap } from './utils.js';
5
+ import { ensureEnv } from './validation.js';
5
6
  export function deleteIt(program) {
6
7
  program
7
8
  .command('delete')
8
9
  .description('Delete a development environment')
9
- .option('--env <env>', 'Environment name (can be git hash). {project}-{env} give env key used to store env state (s3 key in case of AWS)')
10
- .option('--project <project>', 'Project code (like tt). Used when multiple projects deployed in same AWS account')
10
+ .option('--env <env>', KEYS.ENV)
11
+ .option('--project <project>', KEYS.PROJECT)
11
12
  .action(wrap(handler));
12
13
  }
13
14
  async function handler(options) {
14
15
  let { env, project } = options;
15
- if (!env) {
16
- const owner = configService.getOwner();
17
- if (!owner) {
18
- throw new KnownException('--env argument is not provided\n'
19
- + 'default to owner from local configuration, but it is not configured\n'
20
- + 'please call with --env argument or run \'envctl configure\' to configure owner');
21
- }
22
- console.log(`Env name not provided, default to owner name ${owner}`);
23
- env = owner;
24
- }
25
- await envCtl.delete(env, project);
16
+ env = ensureEnv(env);
17
+ await envCtl.delete(project, env);
26
18
  }
@@ -4,18 +4,20 @@ import { configService, envCtl } from '../container.js';
4
4
  import { KnownException } from '../exceptions.js';
5
5
  import { EnvType } from '../model/index.js';
6
6
  import { EnvSize } from '../model/index.js';
7
- import { ensureEnumValue, ensureKind, wrap } from './utils.js';
7
+ import { KEYS } from './keys.js';
8
+ import { wrap } from './utils.js';
9
+ import { ensureEnumValue, ensureKind } from './validation.js';
8
10
  export function deploy(program) {
9
11
  program
10
12
  .command('deploy')
11
13
  .description('Create new or update existing dev environment')
12
- .option('--project <project>', 'Project code (like tt). Used when multiple projects deployed in same AWS account')
13
- .option('--env <env>', 'Environment name (can be git hash). {project}-{env} give env key used to store env state (s3 key in case of AWS)')
14
- .option('--owner <owner>', 'Environment owner (GH username)')
15
- .option('--size <size>', 'Environment size: min, small, full')
16
- .option('--type <type>', 'Environment type: dev, prod', 'dev')
17
- .option('--kind <kind>', 'Environment kind: complete project (default) or some slice such as tt-core + tt-web')
18
- .option('--cwd <cwd>', 'Working directory (default: current directory)')
14
+ .option('--project <project>', KEYS.PROJECT)
15
+ .option('--env <env>', KEYS.ENV)
16
+ .option('--owner <owner>', KEYS.OWNER)
17
+ .option('--size <size>', KEYS.SIZE)
18
+ .option('--type <type>', KEYS.TYPE)
19
+ .option('--kind <kind>', KEYS.KIND)
20
+ .option('--cwd <cwd>', KEYS.CWD)
19
21
  .allowUnknownOption(true)
20
22
  .argument('[args...]')
21
23
  .action(wrap(handler));
@@ -43,15 +45,13 @@ export async function parseEnvDto(options) {
43
45
  envSize = ensureEnumValue(EnvSize, size, 'size');
44
46
  }
45
47
  else {
46
- const answer = await inquirer.prompt([
47
- {
48
+ const answer = await inquirer.prompt([{
48
49
  type: 'list',
49
50
  name: 'size',
50
51
  message: 'Environment size:',
51
52
  choices: Object.values(EnvSize),
52
53
  default: EnvSize.Small,
53
- }
54
- ]);
54
+ }]);
55
55
  envSize = answer.size;
56
56
  }
57
57
  const envType = ensureEnumValue(EnvType, type, 'type');
@@ -2,3 +2,4 @@ export { configure } from './configure.js';
2
2
  export { deploy } from './deploy.js';
3
3
  export { deleteIt } from './delete.js';
4
4
  export { plan } from './plan.js';
5
+ export { status } from './status.js';
@@ -0,0 +1,9 @@
1
+ export const KEYS = {
2
+ CWD: 'Working directory (default: current directory)',
3
+ ENV: 'Environment name (can be git hash). {project}-{env} give env key used to store env state (s3 key in case of AWS)',
4
+ KIND: 'Environment kind: complete project (default) or some slice such as tt-core + tt-web',
5
+ OWNER: 'Environment owner (GH username)',
6
+ PROJECT: 'Project code (like tt). Used when multiple projects deployed in same AWS account',
7
+ SIZE: 'Environment size: min, small, full',
8
+ TYPE: 'Environment type: dev, prod',
9
+ };
@@ -1,17 +1,18 @@
1
1
  import { Command } from 'commander';
2
2
  import { envCtl } from '../container.js';
3
3
  import { parseEnvDto } from './deploy.js';
4
+ import { KEYS } from './keys.js';
4
5
  import { wrap } from './utils.js';
5
6
  export function plan(program) {
6
7
  program
7
8
  .command('plan')
8
9
  .description('High level wrapper for terraform plan. Compliments deploy command: if you plan to deploy env with envctl deploy,'
9
10
  + ' then it is recommended to do plan with envctl plan to guarantee consistent behavior')
10
- .option('--env <env>', 'Environment name (can be git hash). {project}-{env} give env key used to store env state (s3 key in case of AWS)')
11
- .option('--owner <owner>', 'Environment owner (GH username)')
12
- .option('--size <size>', 'Environment size: min, small, full')
13
- .option('--type <type>', 'Environment type: dev, prod', 'dev')
14
- .option('--cwd <cwd>', 'Working directory (default: current directory)')
11
+ .option('--env <env>', KEYS.ENV)
12
+ .option('--owner <owner>', KEYS.OWNER)
13
+ .option('--size <size>', KEYS.SIZE)
14
+ .option('--type <type>', KEYS.TYPE)
15
+ .option('--cwd <cwd>', KEYS.CWD)
15
16
  .allowUnknownOption(true)
16
17
  .argument('[args...]')
17
18
  .action(wrap(handler));
@@ -0,0 +1,18 @@
1
+ import { Command } from 'commander';
2
+ import { envCtl } from '../container.js';
3
+ import { KEYS } from './keys.js';
4
+ import { wrap } from './utils.js';
5
+ import { ensureEnv } from './validation.js';
6
+ export function status(program) {
7
+ program
8
+ .command('status')
9
+ .description('Get env status')
10
+ .option('--env <env>', KEYS.ENV)
11
+ .option('--project <project>', KEYS.PROJECT)
12
+ .action(wrap(handler));
13
+ }
14
+ async function handler(options) {
15
+ let { env, project } = options;
16
+ env = ensureEnv(env);
17
+ await envCtl.status(project, env);
18
+ }
@@ -1,4 +1,3 @@
1
- import path from 'path';
2
1
  import { ExitPromptError } from '@inquirer/core';
3
2
  import inquirer from 'inquirer';
4
3
  import { BusinessException, ExitException, KnownException } from '../exceptions.js';
@@ -25,41 +24,11 @@ export function wrap(callable) {
25
24
  };
26
25
  }
27
26
  export async function promptYesNo(message, defaultValue = false) {
28
- const { answer } = await inquirer.prompt([
29
- {
27
+ const { answer } = await inquirer.prompt([{
30
28
  type: 'confirm',
31
29
  name: 'answer',
32
30
  message,
33
31
  default: defaultValue,
34
- },
35
- ]);
32
+ }]);
36
33
  return answer;
37
34
  }
38
- export function ensureKind(kind, cwd) {
39
- if (kind) {
40
- return kind;
41
- }
42
- kind = getDirName(cwd);
43
- console.log(`Inferred kind from directory name: ${kind}`);
44
- return kind;
45
- }
46
- function getDirName(cwd) {
47
- cwd = resolveCwd(cwd);
48
- return path.basename(cwd);
49
- }
50
- function resolveCwd(cwd) {
51
- if (!cwd) {
52
- return process.cwd();
53
- }
54
- if (path.isAbsolute(cwd)) {
55
- return cwd;
56
- }
57
- return path.resolve(process.cwd(), cwd);
58
- }
59
- export function ensureEnumValue(enumObj, value, name) {
60
- const values = Object.values(enumObj);
61
- if (!values.includes(value)) {
62
- throw new KnownException(`Invalid ${name}: "${value}". Must be one of: ${values.join(', ')}`);
63
- }
64
- return value;
65
- }
@@ -0,0 +1,44 @@
1
+ import path from 'path';
2
+ import { configService } from '../container.js';
3
+ import { KnownException } from '../exceptions.js';
4
+ export function ensureKind(kind, cwd) {
5
+ if (kind) {
6
+ return kind;
7
+ }
8
+ kind = getDirName(cwd);
9
+ console.log(`Inferred kind from directory name: ${kind}`);
10
+ return kind;
11
+ }
12
+ function getDirName(cwd) {
13
+ cwd = resolveCwd(cwd);
14
+ return path.basename(cwd);
15
+ }
16
+ function resolveCwd(cwd) {
17
+ if (!cwd) {
18
+ return process.cwd();
19
+ }
20
+ if (path.isAbsolute(cwd)) {
21
+ return cwd;
22
+ }
23
+ return path.resolve(process.cwd(), cwd);
24
+ }
25
+ export function ensureEnumValue(enumObj, value, name) {
26
+ const values = Object.values(enumObj);
27
+ if (!values.includes(value)) {
28
+ throw new KnownException(`Invalid ${name}: "${value}". Must be one of: ${values.join(', ')}`);
29
+ }
30
+ return value;
31
+ }
32
+ export function ensureEnv(env) {
33
+ if (env) {
34
+ return env;
35
+ }
36
+ const owner = configService.getOwner();
37
+ if (!owner) {
38
+ throw new KnownException('--env argument is not provided\n'
39
+ + 'default to owner from local configuration, but it is not configured\n'
40
+ + 'please call with --env argument or run \'envctl configure\' to configure owner');
41
+ }
42
+ console.log(`Env name not provided, default to owner name ${owner}`);
43
+ return owner;
44
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from 'module';
3
3
  import { Command } from 'commander';
4
4
  import updateNotifier from 'update-notifier';
5
- import { configure, deleteIt, deploy, plan } from './commands/index.js';
5
+ import { configure, deleteIt, deploy, plan, status } from './commands/index.js';
6
6
  const require = createRequire(import.meta.url);
7
7
  const pkg = require('../package.json');
8
8
  updateNotifier({ pkg, updateCheckInterval: 0 }).notify();
@@ -15,4 +15,5 @@ configure(program);
15
15
  deploy(program);
16
16
  deleteIt(program);
17
17
  plan(program);
18
+ status(program);
18
19
  program.parse(process.argv);
@@ -11,8 +11,24 @@ export class EnvCtl {
11
11
  key(project, env) {
12
12
  return project ? `${project}-${env}` : env;
13
13
  }
14
+ async status(project, envName) {
15
+ const key = this.key(project, envName);
16
+ console.log(`Retrieve env ${key}`);
17
+ const env = await this.envApi.get(key);
18
+ if (env === null) {
19
+ console.log(`Env ${key} does not exist`);
20
+ return;
21
+ }
22
+ console.log(`Env ${key} status: ${env.status}`);
23
+ }
14
24
  async plan(envDto, tfArgs, cwd) {
15
- console.log('Deploying resources');
25
+ const key = this.key(envDto.project, envDto.env);
26
+ console.log(`Check if env ${key} already exists`);
27
+ const env = await this.envApi.get(key);
28
+ if (env !== null) {
29
+ this.checkEnv(env, envDto);
30
+ }
31
+ await this.terraformAdapter.init(key, cwd);
16
32
  await this.terraformAdapter.plan(envDto, tfArgs, cwd);
17
33
  }
18
34
  async deploy(envDto, tfArgs, cwd) {
@@ -21,7 +37,6 @@ export class EnvCtl {
21
37
  const env = await this.envApi.get(key);
22
38
  if (env === null) {
23
39
  console.log(`Env ${key} does not exist, creating it`);
24
- console.log('First run terraform init to download providers, this doesn\'t create any resources in AWS even S3 object');
25
40
  await this.terraformAdapter.init(key, cwd);
26
41
  console.log('Creating env tracking record in DynamoDB');
27
42
  const { owner, size, type, kind } = envDto;
@@ -29,21 +44,7 @@ export class EnvCtl {
29
44
  await this.envApi.create(createEnv);
30
45
  return await this.runDeploy(key, envDto, tfArgs, cwd);
31
46
  }
32
- if (env.ephemeral) {
33
- throw new KnownException(`Attempted to convert ephemeral env ${key} to non-ephemeral`);
34
- }
35
- if (env.owner !== envDto.owner) {
36
- throw new KnownException(`Can not change env owner ${env.owner} -> ${envDto.owner}`);
37
- }
38
- if (env.size !== envDto.size) {
39
- throw new KnownException(`Can not change env size ${env.size} -> ${envDto.size}`);
40
- }
41
- if (env.type !== envDto.type) {
42
- throw new KnownException(`Can not change env type ${env.type} -> ${envDto.type}`);
43
- }
44
- if (env.kind !== envDto.kind) {
45
- throw new KnownException(`Can not change env kind ${env.kind} -> ${envDto.kind}`);
46
- }
47
+ this.checkEnv(env, envDto);
47
48
  switch (env.status) {
48
49
  case EnvStatus.Creating:
49
50
  case EnvStatus.Updating: {
@@ -58,14 +59,28 @@ export class EnvCtl {
58
59
  console.log('Env status is ACTIVE\nWill lock for update and run terraform apply (to update resources)');
59
60
  await this.envApi.lockForUpdate(key);
60
61
  await this.runDeploy(key, envDto, tfArgs, cwd);
61
- break;
62
- }
63
- case EnvStatus.Deleting: {
64
- const time = EnvSizeAvgTime[env.size];
65
- throw new KnownException(`Env status is DELETING, please wait (~${time} min)`);
66
62
  }
67
- default:
68
- throw new KnownException(`Unsupported environment status: ${env.status}`);
63
+ }
64
+ }
65
+ checkEnv(env, envDto) {
66
+ if (env.ephemeral) {
67
+ throw new KnownException(`Attempted to convert ephemeral env to non-ephemeral`);
68
+ }
69
+ if (env.owner !== envDto.owner) {
70
+ throw new KnownException(`Can not change env owner ${env.owner} -> ${envDto.owner}`);
71
+ }
72
+ if (env.size !== envDto.size) {
73
+ throw new KnownException(`Can not change env size ${env.size} -> ${envDto.size}`);
74
+ }
75
+ if (env.type !== envDto.type) {
76
+ throw new KnownException(`Can not change env type ${env.type} -> ${envDto.type}`);
77
+ }
78
+ if (env.kind !== envDto.kind) {
79
+ throw new KnownException(`Can not change env kind ${env.kind} -> ${envDto.kind}`);
80
+ }
81
+ if (env.status === EnvStatus.Deleting) {
82
+ const time = EnvSizeAvgTime[env.size];
83
+ throw new KnownException(`Env status is DELETING, please wait (~${time} min)`);
69
84
  }
70
85
  }
71
86
  async runDeploy(key, envDto, tfArgs, cwd) {
@@ -78,7 +93,7 @@ export class EnvCtl {
78
93
  console.log('Unlock env after db evolution');
79
94
  await this.envApi.activate(key);
80
95
  }
81
- async delete(envName, project) {
96
+ async delete(project, envName) {
82
97
  const key = this.key(project, envName);
83
98
  console.log(`Retrieve env`);
84
99
  const env = await this.envApi.get(key);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agilecustoms/envctl",
3
3
  "description": "node.js CLI client for manage environments",
4
- "version": "0.11.0",
4
+ "version": "0.13.0",
5
5
  "author": "Alex Chekulaev",
6
6
  "type": "module",
7
7
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "run": "node dist/index.js",
28
28
  "run-version": "tsc --sourceMap true && npm run run -- --version",
29
29
  "run-configure": "tsc --sourceMap true && npm run run -- configure",
30
+ "run-status": "tsc --sourceMap true && npm run run -- status",
30
31
  "run-plan": "tsc --sourceMap true && npm run run -- plan --size min --cwd ../tt-core",
31
32
  "run-deploy": "tsc --sourceMap true && npm run run -- deploy --size min --cwd ../tt-core",
32
33
  "run-delete": "tsc --sourceMap true && npm run run -- delete"