@agilecustoms/envctl 0.13.1 → 0.14.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.
@@ -0,0 +1,34 @@
1
+ import { fromEnv, fromNodeProviderChain } from '@aws-sdk/credential-providers';
2
+ import { CredentialsProviderError } from '@smithy/property-provider';
3
+ import { KnownException } from '../exceptions.js';
4
+ export class AwsCredsHelper {
5
+ creds;
6
+ async ensureCredentials() {
7
+ await this.getCredentials();
8
+ }
9
+ async getCredentials() {
10
+ if (!this.creds) {
11
+ console.log('Fetching AWS credentials...');
12
+ this.creds = await this._getCredentials();
13
+ }
14
+ return this.creds;
15
+ }
16
+ async _getCredentials() {
17
+ let identityProvider;
18
+ if (process.env.CI) {
19
+ identityProvider = fromEnv();
20
+ }
21
+ else {
22
+ identityProvider = fromNodeProviderChain();
23
+ }
24
+ try {
25
+ return await identityProvider();
26
+ }
27
+ catch (error) {
28
+ if (error instanceof CredentialsProviderError) {
29
+ throw new KnownException(error.message, { cause: error });
30
+ }
31
+ throw new Error('Unknown error fetching credentials:', { cause: error });
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,104 @@
1
+ import path from 'path';
2
+ import inquirer from 'inquirer';
3
+ import { KnownException } from '../exceptions.js';
4
+ import { EnvSize, EnvType } from '../model/index.js';
5
+ import { ConfigService } from '../service/index.js';
6
+ export class CliHelper {
7
+ configService;
8
+ constructor(configService) {
9
+ this.configService = configService;
10
+ }
11
+ async promptYesNo(message, defaultValue = false) {
12
+ const { answer } = await inquirer.prompt([{
13
+ type: 'confirm',
14
+ name: 'answer',
15
+ message,
16
+ default: defaultValue,
17
+ }]);
18
+ return answer;
19
+ }
20
+ ensureEnv(env) {
21
+ if (env) {
22
+ return env;
23
+ }
24
+ const owner = this.configService.getOwner();
25
+ if (!owner) {
26
+ throw new KnownException('--env argument is not provided\n'
27
+ + 'default to owner from local configuration, but it is not configured\n'
28
+ + 'please call with --env argument or run \'envctl configure\' to configure owner');
29
+ }
30
+ console.log(`Env name not provided, default to owner name ${owner}`);
31
+ return owner;
32
+ }
33
+ ensureOwner(owner) {
34
+ if (!owner) {
35
+ owner = this.configService.getOwner();
36
+ if (!owner) {
37
+ throw new KnownException('when called without --owner option, first call \'envctl configure\'');
38
+ }
39
+ console.log(`Owner not provided, default to configured owner ${owner}`);
40
+ }
41
+ return owner;
42
+ }
43
+ async parseEnvInput(input, envName, owner, cwd) {
44
+ owner = this.ensureOwner(owner);
45
+ const { size } = input;
46
+ let { type, kind } = input;
47
+ kind = ensureKind(kind, cwd);
48
+ let envSize;
49
+ if (size) {
50
+ envSize = ensureEnumValue(EnvSize, size, 'size');
51
+ }
52
+ else {
53
+ const answer = await inquirer.prompt([{
54
+ type: 'list',
55
+ name: 'size',
56
+ message: 'Environment size:',
57
+ choices: Object.values(EnvSize),
58
+ default: EnvSize.Small,
59
+ }]);
60
+ envSize = answer.size;
61
+ }
62
+ if (!type) {
63
+ type = EnvType.Dev;
64
+ console.log(`Default environment type: ${EnvType.Dev}`);
65
+ }
66
+ const envType = ensureEnumValue(EnvType, type || EnvType.Dev, 'type');
67
+ return {
68
+ project: input.project,
69
+ env: envName,
70
+ owner,
71
+ size: envSize,
72
+ type: envType,
73
+ kind
74
+ };
75
+ }
76
+ }
77
+ function ensureKind(kind, cwd) {
78
+ if (kind) {
79
+ return kind;
80
+ }
81
+ kind = getDirName(cwd);
82
+ console.log(`Inferred kind from directory name: ${kind}`);
83
+ return kind;
84
+ }
85
+ function getDirName(cwd) {
86
+ cwd = resolveCwd(cwd);
87
+ return path.basename(cwd);
88
+ }
89
+ function resolveCwd(cwd) {
90
+ if (!cwd) {
91
+ return process.cwd();
92
+ }
93
+ if (path.isAbsolute(cwd)) {
94
+ return cwd;
95
+ }
96
+ return path.resolve(process.cwd(), cwd);
97
+ }
98
+ function ensureEnumValue(enumObj, value, name) {
99
+ const values = Object.values(enumObj);
100
+ if (!values.includes(value)) {
101
+ throw new KnownException(`Invalid ${name}: "${value}". Must be one of: ${values.join(', ')}`);
102
+ }
103
+ return value;
104
+ }
@@ -1,4 +1,5 @@
1
1
  import { KnownException, NotFoundException } from '../exceptions.js';
2
+ import { EnvStatus } from '../model/index.js';
2
3
  import { HttpClient } from './HttpClient.js';
3
4
  export class EnvApiClient {
4
5
  httpClient;
@@ -24,31 +25,35 @@ export class EnvApiClient {
24
25
  'Content-Type': 'application/json'
25
26
  }
26
27
  });
27
- return res.token;
28
+ const token = res.token;
29
+ return { ...env, token, status: EnvStatus.Creating };
28
30
  }
29
- async activate(key) {
30
- await this.httpClient.fetch(`/ci/env/${key}/activate`, {
31
+ async activate(env) {
32
+ await this.httpClient.fetch(`/ci/env/${env.key}/activate`, {
31
33
  method: 'POST'
32
34
  });
35
+ env.status = EnvStatus.Active;
33
36
  }
34
- async lockForUpdate(key) {
35
- await this.httpClient.fetch(`/ci/env/${key}/lock-for-update`, {
37
+ async lockForUpdate(env) {
38
+ await this.httpClient.fetch(`/ci/env/${env.key}/lock-for-update`, {
36
39
  method: 'POST'
37
40
  });
41
+ env.status = EnvStatus.Updating;
38
42
  }
39
- async delete(key) {
43
+ async delete(env) {
40
44
  let result;
41
45
  try {
42
- result = await this.httpClient.fetch(`/ci/env/${key}`, {
46
+ result = await this.httpClient.fetch(`/ci/env/${env.key}`, {
43
47
  method: 'DELETE'
44
48
  });
45
49
  }
46
50
  catch (error) {
47
51
  if (error instanceof NotFoundException) {
48
- throw new KnownException(`Environment ${key} is not found`);
52
+ throw new KnownException(`Environment ${env.key} is not found`);
49
53
  }
50
54
  throw error;
51
55
  }
56
+ env.status = EnvStatus.Deleting;
52
57
  return result.statusText;
53
58
  }
54
59
  }
@@ -1,14 +1,13 @@
1
- import { fromEnv, fromNodeProviderChain } from '@aws-sdk/credential-providers';
2
- import { CredentialsProviderError } from '@smithy/property-provider';
3
1
  import aws4 from 'aws4';
4
- import { BusinessException, HttpException, KnownException, NotFoundException } from '../exceptions.js';
2
+ import { BusinessException, HttpException, NotFoundException } from '../exceptions.js';
5
3
  const HOST = 'env-api.maintenance.agilecustoms.com';
6
4
  export class HttpClient {
7
- creds = undefined;
5
+ awsCredsHelper;
6
+ constructor(awsCredsHelper) {
7
+ this.awsCredsHelper = awsCredsHelper;
8
+ }
8
9
  async fetch(path, options = {}) {
9
- if (!this.creds) {
10
- this.creds = await this.getCredentials();
11
- }
10
+ const awsCreds = await this.awsCredsHelper.getCredentials();
12
11
  const requestOptions = {
13
12
  method: options.method,
14
13
  body: options.body,
@@ -17,7 +16,7 @@ export class HttpClient {
17
16
  service: 'execute-api',
18
17
  path,
19
18
  };
20
- const signedOptions = aws4.sign(requestOptions, this.creds);
19
+ const signedOptions = aws4.sign(requestOptions, awsCreds);
21
20
  const signedHeaders = signedOptions.headers;
22
21
  const url = `https://${HOST}${path}`;
23
22
  options.headers = signedHeaders;
@@ -53,22 +52,4 @@ export class HttpClient {
53
52
  }
54
53
  throw new HttpException(response.status, await response.text());
55
54
  }
56
- async getCredentials() {
57
- let identityProvider;
58
- if (process.env.CI) {
59
- identityProvider = fromEnv();
60
- }
61
- else {
62
- identityProvider = fromNodeProviderChain();
63
- }
64
- try {
65
- return await identityProvider();
66
- }
67
- catch (error) {
68
- if (error instanceof CredentialsProviderError) {
69
- throw new KnownException(error.message, { cause: error });
70
- }
71
- throw new Error('Error fetching credentials:', { cause: error });
72
- }
73
- }
74
55
  }
@@ -1,5 +1,4 @@
1
- import { promptYesNo } from '../commands/utils.js';
2
- import { ExitException, KnownException, ProcessException } from '../exceptions.js';
1
+ import { AbortedException, KnownException, ProcessException } from '../exceptions.js';
3
2
  const MAX_ATTEMPTS = 2;
4
3
  const RETRYABLE_ERRORS = [
5
4
  'ConcurrentModificationException',
@@ -7,8 +6,10 @@ const RETRYABLE_ERRORS = [
7
6
  ];
8
7
  export class TerraformAdapter {
9
8
  processRunner;
10
- constructor(processRunner) {
9
+ cliHelper;
10
+ constructor(processRunner, cliHelper) {
11
11
  this.processRunner = processRunner;
12
+ this.cliHelper = cliHelper;
12
13
  }
13
14
  printTime() {
14
15
  const now = new Date();
@@ -41,17 +42,17 @@ export class TerraformAdapter {
41
42
  throw new KnownException('Can not find terraform files. Command needs to be run in a directory with terraform files');
42
43
  }
43
44
  }
44
- tfArgs(envDto, tfArgs) {
45
+ tfArgs(env, tfArgs) {
45
46
  return [
46
- `-var=env=${envDto.env}`,
47
- `-var=owner=${envDto.owner}`,
48
- `-var=env_size=${envDto.size}`,
49
- `-var=env_type=${envDto.type}`,
47
+ `-var=env=${env.env}`,
48
+ `-var=owner=${env.owner}`,
49
+ `-var=env_size=${env.size}`,
50
+ `-var=env_type=${env.type}`,
50
51
  ...tfArgs
51
52
  ];
52
53
  }
53
- async plan(envDto, tfArgs, cwd, attemptNo = 1) {
54
- const args = this.tfArgs(envDto, tfArgs);
54
+ async plan(env, tfArgs, cwd, attemptNo = 1) {
55
+ const args = this.tfArgs(env, tfArgs);
55
56
  console.log('Running: terraform plan -auto-approve', ...args);
56
57
  try {
57
58
  await this.processRunner.run('terraform', ['plan', ...args], cwd);
@@ -60,21 +61,21 @@ export class TerraformAdapter {
60
61
  if (error instanceof ProcessException) {
61
62
  if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
62
63
  console.warn(`Retrying terraform plan due to error: ${error.message}`);
63
- return this.plan(envDto, tfArgs, cwd, attemptNo + 1);
64
+ return this.plan(env, tfArgs, cwd, attemptNo + 1);
64
65
  }
65
- const lockId = this.lockId(attemptNo, error);
66
+ const lockId = this.lockId(error, attemptNo);
66
67
  if (lockId) {
67
68
  await this.promptUnlock(lockId, cwd);
68
69
  console.info('State unlocked, retrying terraform plan');
69
- return this.plan(envDto, tfArgs, cwd, attemptNo + 1);
70
+ return this.plan(env, tfArgs, cwd, attemptNo + 1);
70
71
  }
71
72
  throw new KnownException(`terraform plan failed with code ${error.code}:\n${error.message}`, { cause: error });
72
73
  }
73
74
  throw error;
74
75
  }
75
76
  }
76
- async deploy(envDto, tfArgs, cwd, attemptNo = 1) {
77
- const args = this.tfArgs(envDto, tfArgs);
77
+ async apply(env, tfArgs, cwd, attemptNo = 1) {
78
+ const args = this.tfArgs(env, tfArgs);
78
79
  console.log('Running: terraform apply -auto-approve', ...args);
79
80
  this.printTime();
80
81
  try {
@@ -85,20 +86,48 @@ export class TerraformAdapter {
85
86
  if (error instanceof ProcessException) {
86
87
  if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
87
88
  console.warn(`Retrying terraform apply due to error: ${error.message}`);
88
- return this.deploy(envDto, tfArgs, cwd, attemptNo + 1);
89
+ return this.apply(env, tfArgs, cwd, attemptNo + 1);
89
90
  }
90
- const lockId = this.lockId(attemptNo, error);
91
+ const lockId = this.lockId(error, attemptNo);
91
92
  if (lockId) {
92
93
  await this.promptUnlock(lockId, cwd);
93
94
  console.info('State unlocked, retrying terraform apply');
94
- return this.deploy(envDto, tfArgs, cwd, attemptNo + 1);
95
+ return this.apply(env, tfArgs, cwd, attemptNo + 1);
95
96
  }
96
97
  throw new KnownException(`terraform apply failed with code ${error.code}:\n${error.message}`, { cause: error });
97
98
  }
98
99
  throw error;
99
100
  }
100
101
  }
101
- lockId(attemptNo, error) {
102
+ async destroy(env, tfArgs, cwd, attemptNo = 1) {
103
+ let wrongDir = false;
104
+ const scanner = (line) => {
105
+ if (line.includes('Either you have not created any objects yet or the existing objects were')) {
106
+ wrongDir = true;
107
+ }
108
+ };
109
+ const args = this.tfArgs(env, tfArgs);
110
+ console.log('Running: terraform destroy -auto-approve');
111
+ try {
112
+ await this.processRunner.run('terraform', ['destroy', '-auto-approve', ...args], cwd, scanner);
113
+ }
114
+ catch (error) {
115
+ if (error instanceof ProcessException) {
116
+ const lockId = this.lockId(error, attemptNo);
117
+ if (lockId) {
118
+ await this.promptUnlock(lockId, cwd);
119
+ console.info('State unlocked, retrying terraform destroy');
120
+ return this.destroy(env, tfArgs, cwd, attemptNo + 1);
121
+ }
122
+ throw new KnownException(`terraform destroy failed with code ${error.code}:\n${error.message}`, { cause: error });
123
+ }
124
+ throw error;
125
+ }
126
+ if (wrongDir) {
127
+ throw new KnownException('Can not find terraform files. Command needs to be run in a directory with terraform files');
128
+ }
129
+ }
130
+ lockId(error, attemptNo = 1) {
102
131
  if (attemptNo < MAX_ATTEMPTS && error.message.includes('Error acquiring the state lock')) {
103
132
  const match = error.message.match(/ID:\s+([a-f0-9-]{36})/i);
104
133
  if (match) {
@@ -108,11 +137,11 @@ export class TerraformAdapter {
108
137
  }
109
138
  }
110
139
  async promptUnlock(id, cwd) {
111
- const answerYes = await promptYesNo('Terraform state is locked. Most often due to previously interrupted process.\n'
140
+ const answerYes = await this.cliHelper.promptYesNo('Terraform state is locked. Most often due to previously interrupted process.\n'
112
141
  + 'Rarely (main lock intention) is when another process actively work with the state to avoid inconsistent modification.\n'
113
142
  + 'Do you want to force unlock?: ');
114
143
  if (!answerYes) {
115
- throw new ExitException();
144
+ throw new AbortedException();
116
145
  }
117
146
  console.log('Force unlocking state');
118
147
  await this.processRunner.run('terraform', ['force-unlock', '-force', id], cwd);
@@ -1,3 +1,4 @@
1
+ export { CliHelper } from './CliHelper.js';
1
2
  export { EnvApiClient } from './EnvApiClient.js';
2
3
  export { HttpClient } from './HttpClient.js';
3
4
  export { TerraformAdapter } from './TerraformAdapter.js';
@@ -1,4 +1,4 @@
1
- export const KEYS = {
1
+ export const _keys = {
2
2
  CWD: 'Working directory (default: current directory)',
3
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
4
  KIND: 'Environment kind: complete project (default) or some slice such as tt-core + tt-web',
@@ -1,6 +1,5 @@
1
1
  import { ExitPromptError } from '@inquirer/core';
2
- import inquirer from 'inquirer';
3
- import { BusinessException, ExitException, KnownException } from '../exceptions.js';
2
+ import { BusinessException, AbortedException, KnownException } from '../exceptions.js';
4
3
  export function wrap(callable) {
5
4
  return async (...args) => {
6
5
  let result;
@@ -11,7 +10,7 @@ export function wrap(callable) {
11
10
  if (error instanceof KnownException || error instanceof BusinessException) {
12
11
  console.error(error.message);
13
12
  }
14
- else if (error instanceof ExitException || error instanceof ExitPromptError) {
13
+ else if (error instanceof AbortedException || error instanceof ExitPromptError) {
15
14
  }
16
15
  else {
17
16
  console.error('Unknown error:', error);
@@ -23,12 +22,3 @@ export function wrap(callable) {
23
22
  }
24
23
  };
25
24
  }
26
- export async function promptYesNo(message, defaultValue = false) {
27
- const { answer } = await inquirer.prompt([{
28
- type: 'confirm',
29
- name: 'answer',
30
- message,
31
- default: defaultValue,
32
- }]);
33
- return answer;
34
- }
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import inquirer from 'inquirer';
3
3
  import { configService } from '../container.js';
4
- import { wrap } from './utils.js';
4
+ import { wrap } from './_utils.js';
5
5
  export function configure(program) {
6
6
  program
7
7
  .command('configure')
@@ -1,18 +1,17 @@
1
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';
2
+ import { cliHelper, envCtl } from '../container.js';
3
+ import { _keys } from './_keys.js';
4
+ import { wrap } from './_utils.js';
6
5
  export function deleteIt(program) {
7
6
  program
8
7
  .command('delete')
9
8
  .description('Delete a development environment')
10
- .option('--env <env>', KEYS.ENV)
11
- .option('--project <project>', KEYS.PROJECT)
9
+ .option('--project <project>', _keys.PROJECT)
10
+ .option('--env <env>', _keys.ENV)
12
11
  .action(wrap(handler));
13
12
  }
14
13
  async function handler(options) {
15
- let { env, project } = options;
16
- env = ensureEnv(env);
17
- await envCtl.delete(project, env);
14
+ const { project, env } = options;
15
+ const envName = cliHelper.ensureEnv(env);
16
+ await envCtl.delete(project, envName);
18
17
  }
@@ -1,59 +1,24 @@
1
1
  import { Command } from 'commander';
2
- import inquirer from 'inquirer';
3
- import { configService, envCtl } from '../container.js';
4
- import { KnownException } from '../exceptions.js';
5
- import { EnvType } from '../model/index.js';
6
- import { EnvSize } from '../model/index.js';
7
- import { KEYS } from './keys.js';
8
- import { wrap } from './utils.js';
9
- import { ensureEnumValue, ensureKind } from './validation.js';
2
+ import { awsCredsHelper, envCtl } from '../container.js';
3
+ import { _keys } from './_keys.js';
4
+ import { wrap } from './_utils.js';
10
5
  export function deploy(program) {
11
6
  program
12
7
  .command('deploy')
13
8
  .description('Create new or update existing dev environment')
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, EnvType.Dev)
19
- .option('--kind <kind>', KEYS.KIND)
20
- .option('--cwd <cwd>', KEYS.CWD)
9
+ .option('--project <project>', _keys.PROJECT)
10
+ .option('--env <env>', _keys.ENV)
11
+ .option('--owner <owner>', _keys.OWNER)
12
+ .option('--size <size>', _keys.SIZE)
13
+ .option('--type <type>', _keys.TYPE)
14
+ .option('--kind <kind>', _keys.KIND)
15
+ .option('--cwd <cwd>', _keys.CWD)
21
16
  .allowUnknownOption(true)
22
17
  .argument('[args...]')
23
18
  .action(wrap(handler));
24
19
  }
25
20
  async function handler(tfArgs, options) {
26
- const envDto = await parseEnvDto(options);
27
- await envCtl.deploy(envDto, tfArgs, options.cwd);
28
- }
29
- export async function parseEnvDto(options) {
30
- let { project, env, owner, size, type, kind, cwd } = options;
31
- if (!owner) {
32
- owner = configService.getOwner();
33
- if (!owner) {
34
- throw new KnownException('when called without --owner option, first call \'envctl configure\'');
35
- }
36
- console.log(`Owner not provided, default to configured owner ${owner}`);
37
- }
38
- if (!env) {
39
- console.log(`Env name not provided, default to owner name ${owner}`);
40
- env = owner;
41
- }
42
- kind = ensureKind(kind, cwd);
43
- let envSize;
44
- if (size) {
45
- envSize = ensureEnumValue(EnvSize, size, 'size');
46
- }
47
- else {
48
- const answer = await inquirer.prompt([{
49
- type: 'list',
50
- name: 'size',
51
- message: 'Environment size:',
52
- choices: Object.values(EnvSize),
53
- default: EnvSize.Small,
54
- }]);
55
- envSize = answer.size;
56
- }
57
- const envType = ensureEnumValue(EnvType, type, 'type');
58
- return { project, env, owner, size: envSize, type: envType, kind };
21
+ await awsCredsHelper.ensureCredentials();
22
+ const { cwd, ...envDto } = options;
23
+ await envCtl.deploy(envDto, tfArgs, cwd);
59
24
  }
@@ -0,0 +1,22 @@
1
+ import { Command } from 'commander';
2
+ import { cliHelper, envCtl } from '../container.js';
3
+ import { _keys } from './_keys.js';
4
+ import { wrap } from './_utils.js';
5
+ export function destroy(program) {
6
+ program
7
+ .command('destroy')
8
+ .description('Destroy environment resources. This is thin wrapper for terraform destroy.'
9
+ + ' Unlike "delete" command (which just schedule deletion) this command deletes resources synchronously.'
10
+ + ' Main use case - test deletion process, basically that you have enough permissions to delete resources')
11
+ .option('--project <project>', _keys.PROJECT)
12
+ .option('--env <env>', _keys.ENV)
13
+ .option('--cwd <cwd>', _keys.CWD)
14
+ .allowUnknownOption(true)
15
+ .argument('[args...]')
16
+ .action(wrap(handler));
17
+ }
18
+ async function handler(tfArgs, options) {
19
+ const { project, env, cwd } = options;
20
+ const envName = cliHelper.ensureEnv(env);
21
+ await envCtl.destroy(project, envName, tfArgs, cwd);
22
+ }
@@ -1,5 +1,6 @@
1
1
  export { configure } from './configure.js';
2
- export { deploy } from './deploy.js';
3
2
  export { deleteIt } from './delete.js';
3
+ export { deploy } from './deploy.js';
4
+ export { destroy } from './destroy.js';
4
5
  export { plan } from './plan.js';
5
6
  export { status } from './status.js';
@@ -1,24 +1,24 @@
1
1
  import { Command } from 'commander';
2
- import { envCtl } from '../container.js';
3
- import { EnvType } from '../model/index.js';
4
- import { parseEnvDto } from './deploy.js';
5
- import { KEYS } from './keys.js';
6
- import { wrap } from './utils.js';
2
+ import { awsCredsHelper, envCtl } from '../container.js';
3
+ import { _keys } from './_keys.js';
4
+ import { wrap } from './_utils.js';
7
5
  export function plan(program) {
8
6
  program
9
7
  .command('plan')
10
8
  .description('High level wrapper for terraform plan. Compliments deploy command: if you plan to deploy env with envctl deploy,'
11
9
  + ' then it is recommended to do plan with envctl plan to guarantee consistent behavior')
12
- .option('--env <env>', KEYS.ENV)
13
- .option('--owner <owner>', KEYS.OWNER)
14
- .option('--size <size>', KEYS.SIZE)
15
- .option('--type <type>', KEYS.TYPE, EnvType.Dev)
16
- .option('--cwd <cwd>', KEYS.CWD)
10
+ .option('--project <project>', _keys.PROJECT)
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)
17
16
  .allowUnknownOption(true)
18
17
  .argument('[args...]')
19
18
  .action(wrap(handler));
20
19
  }
21
20
  async function handler(tfArgs, options) {
22
- const envDto = await parseEnvDto(options);
23
- await envCtl.plan(envDto, tfArgs, options.cwd);
21
+ await awsCredsHelper.ensureCredentials();
22
+ const { cwd, ...envDto } = options;
23
+ await envCtl.plan(envDto, tfArgs, cwd);
24
24
  }
@@ -1,18 +1,17 @@
1
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';
2
+ import { cliHelper, envCtl } from '../container.js';
3
+ import { _keys } from './_keys.js';
4
+ import { wrap } from './_utils.js';
6
5
  export function status(program) {
7
6
  program
8
7
  .command('status')
9
8
  .description('Get env status')
10
- .option('--env <env>', KEYS.ENV)
11
- .option('--project <project>', KEYS.PROJECT)
9
+ .option('--project <project>', _keys.PROJECT)
10
+ .option('--env <env>', _keys.ENV)
12
11
  .action(wrap(handler));
13
12
  }
14
13
  async function handler(options) {
15
- let { env, project } = options;
16
- env = ensureEnv(env);
17
- await envCtl.status(project, env);
14
+ const { project, env } = options;
15
+ const envName = cliHelper.ensureEnv(env);
16
+ await envCtl.status(project, envName);
18
17
  }
package/dist/container.js CHANGED
@@ -1,10 +1,13 @@
1
- import { EnvApiClient, HttpClient, TerraformAdapter } from './client/index.js';
1
+ import { AwsCredsHelper } from './client/AwsCredsHelper.js';
2
+ import { CliHelper, EnvApiClient, HttpClient, TerraformAdapter } from './client/index.js';
2
3
  import { ProcessRunner } from './client/ProcessRunner.js';
3
4
  import { ConfigService, EnvCtl } from './service/index.js';
4
5
  const configService = new ConfigService();
5
- const httpClient = new HttpClient();
6
+ const cliHelper = new CliHelper(configService);
7
+ const awsCredsHelper = new AwsCredsHelper();
8
+ const httpClient = new HttpClient(awsCredsHelper);
6
9
  const envApiClient = new EnvApiClient(httpClient);
7
10
  const processRunner = new ProcessRunner();
8
- const terraformAdapter = new TerraformAdapter(processRunner);
9
- const envCtl = new EnvCtl(envApiClient, terraformAdapter);
10
- export { configService, envCtl };
11
+ const terraformAdapter = new TerraformAdapter(processRunner, cliHelper);
12
+ const envCtl = new EnvCtl(cliHelper, envApiClient, terraformAdapter);
13
+ export { awsCredsHelper, cliHelper, configService, envCtl };
@@ -4,10 +4,10 @@ export class KnownException extends Error {
4
4
  this.name = 'KnownException';
5
5
  }
6
6
  }
7
- export class ExitException extends Error {
7
+ export class AbortedException extends Error {
8
8
  constructor() {
9
9
  super();
10
- this.name = 'ExitException';
10
+ this.name = 'AbortedException';
11
11
  }
12
12
  }
13
13
  export class HttpException extends Error {
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, status } from './commands/index.js';
5
+ import { configure, deleteIt, deploy, destroy, 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();
@@ -12,8 +12,9 @@ program
12
12
  .description('CLI to manage environments')
13
13
  .version(pkg.version);
14
14
  configure(program);
15
- deploy(program);
16
15
  deleteIt(program);
16
+ deploy(program);
17
+ destroy(program);
17
18
  plan(program);
18
19
  status(program);
19
20
  program.parse(process.argv);
@@ -1,10 +1,11 @@
1
- import { promptYesNo } from '../commands/utils.js';
2
1
  import { KnownException } from '../exceptions.js';
3
2
  import { EnvSizeAvgTime, EnvStatus } from '../model/index.js';
4
3
  export class EnvCtl {
4
+ cliHelper;
5
5
  envApi;
6
6
  terraformAdapter;
7
- constructor(envApi, terraformAdapter) {
7
+ constructor(cliHelper, envApi, terraformAdapter) {
8
+ this.cliHelper = cliHelper;
8
9
  this.envApi = envApi;
9
10
  this.terraformAdapter = terraformAdapter;
10
11
  }
@@ -21,95 +22,159 @@ export class EnvCtl {
21
22
  }
22
23
  console.log(`Env ${key} status: ${env.status}`);
23
24
  }
24
- async plan(envDto, tfArgs, cwd) {
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);
25
+ async tryGetEnv(input) {
26
+ let envName = input.env;
27
+ let owner = input.owner;
28
+ if (!envName) {
29
+ owner = this.cliHelper.ensureOwner(owner);
30
+ console.log(`Env name not provided, default to owner name ${owner}`);
31
+ envName = owner;
30
32
  }
31
- await this.terraformAdapter.init(key, cwd);
32
- await this.terraformAdapter.plan(envDto, tfArgs, cwd);
33
- }
34
- async deploy(envDto, tfArgs, cwd) {
35
- const key = this.key(envDto.project, envDto.env);
33
+ const key = this.key(input.project, envName);
36
34
  console.log(`Check if env ${key} already exists`);
37
35
  const env = await this.envApi.get(key);
38
- if (env === null) {
39
- console.log(`Env ${key} does not exist, creating it`);
40
- await this.terraformAdapter.init(key, cwd);
41
- console.log('Creating env tracking record in DynamoDB');
42
- const { owner, size, type, kind } = envDto;
43
- const createEnv = { key, ephemeral: false, owner, size, type, kind };
44
- await this.envApi.create(createEnv);
45
- return await this.runDeploy(key, envDto, tfArgs, cwd);
46
- }
47
- this.checkEnv(env, envDto);
48
- switch (env.status) {
49
- case EnvStatus.Creating:
50
- case EnvStatus.Updating: {
51
- const answerYes = await promptYesNo(`Env status is ${env.status}, likely to be an error from a previous run\n`
52
- + 'Do you want to proceed with deployment?');
53
- if (answerYes) {
54
- await this.runDeploy(key, envDto, tfArgs, cwd);
55
- }
56
- break;
57
- }
58
- case EnvStatus.Active: {
59
- console.log('Env status is ACTIVE\nWill lock for update and run terraform apply (to update resources)');
60
- await this.envApi.lockForUpdate(key);
61
- await this.runDeploy(key, envDto, tfArgs, cwd);
62
- }
36
+ if (env) {
37
+ console.log(`Found ${key} env`);
38
+ }
39
+ else {
40
+ console.log(`Env ${key} does not exist`);
63
41
  }
42
+ return { envName, owner, key, env };
64
43
  }
65
- checkEnv(env, envDto) {
44
+ checkInput(envName, env, input) {
66
45
  if (env.ephemeral) {
67
46
  throw new KnownException(`Attempted to convert ephemeral env to non-ephemeral`);
68
47
  }
69
- if (env.owner !== envDto.owner) {
70
- throw new KnownException(`Can not change env owner ${env.owner} -> ${envDto.owner}`);
48
+ if (input.owner && env.owner !== input.owner) {
49
+ throw new KnownException(`Can not change env owner ${env.owner} -> ${input.owner}`);
71
50
  }
72
- if (env.size !== envDto.size) {
73
- throw new KnownException(`Can not change env size ${env.size} -> ${envDto.size}`);
51
+ if (input.size && env.size !== input.size) {
52
+ throw new KnownException(`Can not change env size ${env.size} -> ${input.size}`);
74
53
  }
75
- if (env.type !== envDto.type) {
76
- throw new KnownException(`Can not change env type ${env.type} -> ${envDto.type}`);
54
+ if (input.type && env.type !== input.type) {
55
+ throw new KnownException(`Can not change env type ${env.type} -> ${input.type}`);
77
56
  }
78
- if (env.kind !== envDto.kind) {
79
- throw new KnownException(`Can not change env kind ${env.kind} -> ${envDto.kind}`);
57
+ if (input.kind && env.kind !== input.kind) {
58
+ throw new KnownException(`Can not change env kind ${env.kind} -> ${input.kind}`);
59
+ }
60
+ const { owner, size, type, kind } = env;
61
+ return {
62
+ project: input.project, env: envName,
63
+ owner, size, type, kind,
64
+ };
65
+ }
66
+ checkStatus(env, availableStatuses = [EnvStatus.Active, EnvStatus.Creating, EnvStatus.Updating]) {
67
+ if (availableStatuses.includes(env.status)) {
68
+ return;
80
69
  }
81
70
  if (env.status === EnvStatus.Deleting) {
82
71
  const time = EnvSizeAvgTime[env.size];
83
72
  throw new KnownException(`Env status is DELETING, please wait (~${time} min)`);
84
73
  }
74
+ throw new KnownException(`Env status is ${env.status}, can not run this command`);
75
+ }
76
+ async plan(input, tfArgs, cwd) {
77
+ const { envName, owner, key, env } = await this.tryGetEnv(input);
78
+ if (env == null) {
79
+ const envInput = await this.cliHelper.parseEnvInput(input, envName, owner, cwd);
80
+ await this.terraformAdapter.init(key, cwd);
81
+ await this.terraformAdapter.plan(envInput, tfArgs, cwd);
82
+ return;
83
+ }
84
+ const envInput = this.checkInput(envName, env, input);
85
+ this.checkStatus(env);
86
+ await this.promptUnlock(env);
87
+ if (env.status !== EnvStatus.Active) {
88
+ throw new KnownException(`Env ${env.key} status is ${env.status}, can not run plan`);
89
+ }
90
+ await this.terraformAdapter.plan(envInput, tfArgs, cwd);
91
+ }
92
+ async deploy(input, tfArgs, cwd) {
93
+ const { envName, owner, key, env } = await this.tryGetEnv(input);
94
+ if (env === null) {
95
+ const envInput = await this.cliHelper.parseEnvInput(input, envName, owner, cwd);
96
+ await this.terraformAdapter.init(key, cwd);
97
+ console.log('Creating env tracking record in DynamoDB');
98
+ const { size, type, kind } = envInput;
99
+ const createEnv = { key, ephemeral: false, owner: envInput.owner, size, type, kind };
100
+ const newEnv = await this.envApi.create(createEnv);
101
+ return await this.runDeploy(newEnv, envName, tfArgs, cwd);
102
+ }
103
+ this.checkInput(envName, env, input);
104
+ this.checkStatus(env);
105
+ if (env.status == EnvStatus.Creating || env.status == EnvStatus.Updating) {
106
+ const answerYes = await this.cliHelper.promptYesNo(`Env status is ${env.status}, likely to be an error from a previous run\n`
107
+ + 'Do you want to proceed with deployment?');
108
+ if (!answerYes) {
109
+ console.log('Aborting deployment');
110
+ return;
111
+ }
112
+ }
113
+ if (env.status === EnvStatus.Active) {
114
+ console.log('Env status is ACTIVE\nWill lock for update and run terraform apply (to update resources)');
115
+ await this.envApi.lockForUpdate(env);
116
+ }
117
+ await this.runDeploy(env, envName, tfArgs, cwd);
85
118
  }
86
- async runDeploy(key, envDto, tfArgs, cwd) {
119
+ async runDeploy(env, envName, tfArgs, cwd) {
87
120
  console.log('Deploying resources');
88
- await this.terraformAdapter.deploy(envDto, tfArgs, cwd);
121
+ const { owner, size, type } = env;
122
+ const envTf = { owner, size, type, env: envName };
123
+ await this.terraformAdapter.apply(envTf, tfArgs, cwd);
89
124
  console.log('Activating env (to finish creation)');
90
- await this.envApi.activate(key);
125
+ await this.envApi.activate(env);
91
126
  console.log('Lock env to run db evolution');
92
- await this.envApi.lockForUpdate(key);
127
+ await this.envApi.lockForUpdate(env);
93
128
  console.log('Unlock env after db evolution');
94
- await this.envApi.activate(key);
129
+ await this.envApi.activate(env);
95
130
  }
96
131
  async delete(project, envName) {
132
+ const env = await this.get(project, envName);
133
+ this.checkStatus(env);
134
+ await this.promptUnlock(env);
135
+ if (env.status !== EnvStatus.Active) {
136
+ throw new KnownException(`Env ${env.key} status is ${env.status}, can not delete`);
137
+ }
138
+ console.log('Deleting env');
139
+ const statusMessage = await this.envApi.delete(env);
140
+ console.log(statusMessage);
141
+ }
142
+ async destroy(project, envName, tfArgs, cwd) {
143
+ const env = await this.get(project, envName);
144
+ this.checkStatus(env);
145
+ await this.promptUnlock(env, [EnvStatus.Creating]);
146
+ if (env.status === EnvStatus.Active) {
147
+ console.log('Lock env to run destroy');
148
+ await this.envApi.lockForUpdate(env);
149
+ }
150
+ const { owner, size, type } = env;
151
+ const tfEnv = { owner, size, type, env: envName };
152
+ console.log('Destroying env resources');
153
+ await this.terraformAdapter.destroy(tfEnv, tfArgs, cwd);
154
+ console.log('Unlock env');
155
+ await this.envApi.activate(env);
156
+ console.log('Schedule env metadata deletion');
157
+ const statusMessage = await this.envApi.delete(env);
158
+ console.log(statusMessage);
159
+ console.log('Please wait for ~1 min before you can create env with same name');
160
+ }
161
+ async get(project, envName) {
97
162
  const key = this.key(project, envName);
98
- console.log(`Retrieve env`);
163
+ console.log(`Retrieve env ${key}`);
99
164
  const env = await this.envApi.get(key);
100
- if (env === null) {
165
+ if (!env) {
101
166
  throw new KnownException(`Environment ${key} does not exist`);
102
167
  }
103
- if (env.status === EnvStatus.Creating) {
104
- const answerYes = await promptYesNo('Environment is still being created.\nDo you want to delete it?');
105
- if (!answerYes) {
106
- throw new KnownException('Aborting env deletion');
168
+ return env;
169
+ }
170
+ async promptUnlock(env, statuses = [EnvStatus.Creating, EnvStatus.Updating]) {
171
+ if (statuses.includes(env.status)) {
172
+ const answerYes = await this.cliHelper.promptYesNo(`Env status is ${env.status}, likely to be an error from a previous run\n`
173
+ + 'Do you want to unlock it?');
174
+ if (answerYes) {
175
+ await this.envApi.activate(env);
176
+ env.status = EnvStatus.Active;
107
177
  }
108
- console.log('Activate (unlock) env (so it can be deleted)');
109
- await this.envApi.activate(key);
110
178
  }
111
- console.log('Deleting env');
112
- const statusMessage = await this.envApi.delete(key);
113
- console.log(statusMessage);
114
179
  }
115
180
  }
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.13.1",
4
+ "version": "0.14.0",
5
5
  "author": "Alex Chekulaev",
6
6
  "type": "module",
7
7
  "bin": {
@@ -30,7 +30,8 @@
30
30
  "run-status": "tsc --sourceMap true && npm run run -- status",
31
31
  "run-plan": "tsc --sourceMap true && npm run run -- plan --size min --cwd ../tt-core",
32
32
  "run-deploy": "tsc --sourceMap true && npm run run -- deploy --size min --cwd ../tt-core",
33
- "run-delete": "tsc --sourceMap true && npm run run -- delete"
33
+ "run-delete": "tsc --sourceMap true && npm run run -- delete",
34
+ "run-destroy": "tsc --sourceMap true && npm run run -- destroy --cwd ../tt-core"
34
35
  },
35
36
  "dependencies": {
36
37
  "@aws-sdk/client-sts": "^3.716.0",
@@ -1,44 +0,0 @@
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
- }