@agilecustoms/envctl 0.10.0 → 0.12.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.
- package/README.md +1 -1
- package/dist/client/ProcessRunner.js +7 -1
- package/dist/client/TerraformAdapter.js +59 -26
- package/dist/commands/deploy.js +7 -6
- package/dist/commands/index.js +1 -0
- package/dist/commands/plan.js +22 -0
- package/dist/commands/utils.js +2 -4
- package/dist/index.js +2 -1
- package/dist/service/EnvCtl.js +41 -29
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ npm view @agilecustoms/envctl version # show latest version available (without i
|
|
|
33
33
|
`env-api` is a microservice hosted in 'maintenance' account and working as garbage collector: every environment first
|
|
34
34
|
created in `env-api` and then 'managed' by `env-api`: it deletes env when it is not in use anymore OR can extend lifetime.
|
|
35
35
|
Creation API yields unique ID, so you can safely manage env (delete, extend lifetime) via this ID. But creation API
|
|
36
|
-
|
|
36
|
+
needs to be secured. There are two main use cases:
|
|
37
37
|
1. create environment from CI (mainly ephemeral envs)
|
|
38
38
|
2. create env from dev machine
|
|
39
39
|
|
|
@@ -9,7 +9,9 @@ export class ProcessRunner {
|
|
|
9
9
|
return this.run(scriptPath, args, cwd, scanner);
|
|
10
10
|
}
|
|
11
11
|
async run(command, args, cwd, scanner) {
|
|
12
|
-
const spawnOptions = {
|
|
12
|
+
const spawnOptions = {
|
|
13
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
14
|
+
};
|
|
13
15
|
if (cwd) {
|
|
14
16
|
spawnOptions.cwd = cwd;
|
|
15
17
|
}
|
|
@@ -26,6 +28,10 @@ export class ProcessRunner {
|
|
|
26
28
|
scanner(line);
|
|
27
29
|
console.log(`> ${line}`);
|
|
28
30
|
}
|
|
31
|
+
if (buffer.includes('Enter a value:')) {
|
|
32
|
+
console.log(buffer);
|
|
33
|
+
buffer = '';
|
|
34
|
+
}
|
|
29
35
|
});
|
|
30
36
|
let errorBuffer = '';
|
|
31
37
|
child.stderr.on('data', (data) => {
|
|
@@ -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,15 +41,41 @@ 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
|
-
|
|
44
|
-
|
|
45
|
-
`-var=env=${
|
|
46
|
-
`-var=owner=${
|
|
47
|
-
`-var=env_size=${
|
|
48
|
-
`-var=env_type=${
|
|
44
|
+
tfArgs(envDto, tfArgs) {
|
|
45
|
+
return [
|
|
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
|
+
}
|
|
53
|
+
async plan(envDto, tfArgs, cwd, attemptNo = 1) {
|
|
54
|
+
const args = this.tfArgs(envDto, tfArgs);
|
|
55
|
+
console.log('Running: terraform plan -auto-approve', ...args);
|
|
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
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async deploy(envDto, tfArgs, cwd, attemptNo = 1) {
|
|
77
|
+
const args = this.tfArgs(envDto, tfArgs);
|
|
78
|
+
console.log('Running: terraform apply -auto-approve', ...args);
|
|
52
79
|
this.printTime();
|
|
53
80
|
try {
|
|
54
81
|
await this.processRunner.run('terraform', ['apply', '-auto-approve', ...args], cwd);
|
|
@@ -58,30 +85,36 @@ export class TerraformAdapter {
|
|
|
58
85
|
if (error instanceof ProcessException) {
|
|
59
86
|
if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
|
|
60
87
|
console.warn(`Retrying terraform apply due to error: ${error.message}`);
|
|
61
|
-
return this.deploy(
|
|
88
|
+
return this.deploy(envDto, tfArgs, cwd, attemptNo + 1);
|
|
62
89
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
+ 'Rarely (main lock intention) is when another process actively work with the state to avoid inconsistent modification.\n'
|
|
69
|
-
+ 'Do you want to force unlock? (y/n): ');
|
|
70
|
-
if (answerYes) {
|
|
71
|
-
console.log('Force unlocking state');
|
|
72
|
-
const id = match[1];
|
|
73
|
-
await this.processRunner.run('terraform', ['force-unlock', '-force', id], cwd);
|
|
74
|
-
console.info('State unlocked, retrying terraform apply');
|
|
75
|
-
return this.deploy(envAttrs, tfArgs, cwd, attemptNo + 1);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
throw new ExitException();
|
|
79
|
-
}
|
|
80
|
-
}
|
|
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);
|
|
81
95
|
}
|
|
82
96
|
throw new KnownException(`terraform apply failed with code ${error.code}:\n${error.message}`, { cause: error });
|
|
83
97
|
}
|
|
84
98
|
throw error;
|
|
85
99
|
}
|
|
86
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
|
+
}
|
|
87
120
|
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -21,6 +21,10 @@ export function deploy(program) {
|
|
|
21
21
|
.action(wrap(handler));
|
|
22
22
|
}
|
|
23
23
|
async function handler(tfArgs, options) {
|
|
24
|
+
const envDto = await parseEnvDto(options);
|
|
25
|
+
await envCtl.deploy(envDto, tfArgs, options.cwd);
|
|
26
|
+
}
|
|
27
|
+
export async function parseEnvDto(options) {
|
|
24
28
|
let { project, env, owner, size, type, kind, cwd } = options;
|
|
25
29
|
if (!owner) {
|
|
26
30
|
owner = configService.getOwner();
|
|
@@ -39,18 +43,15 @@ async function handler(tfArgs, options) {
|
|
|
39
43
|
envSize = ensureEnumValue(EnvSize, size, 'size');
|
|
40
44
|
}
|
|
41
45
|
else {
|
|
42
|
-
const answer = await inquirer.prompt([
|
|
43
|
-
{
|
|
46
|
+
const answer = await inquirer.prompt([{
|
|
44
47
|
type: 'list',
|
|
45
48
|
name: 'size',
|
|
46
49
|
message: 'Environment size:',
|
|
47
50
|
choices: Object.values(EnvSize),
|
|
48
51
|
default: EnvSize.Small,
|
|
49
|
-
}
|
|
50
|
-
]);
|
|
52
|
+
}]);
|
|
51
53
|
envSize = answer.size;
|
|
52
54
|
}
|
|
53
55
|
const envType = ensureEnumValue(EnvType, type, 'type');
|
|
54
|
-
|
|
55
|
-
await envCtl.deploy(envAttributes, tfArgs, options.cwd);
|
|
56
|
+
return { project, env, owner, size: envSize, type: envType, kind };
|
|
56
57
|
}
|
package/dist/commands/index.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { envCtl } from '../container.js';
|
|
3
|
+
import { parseEnvDto } from './deploy.js';
|
|
4
|
+
import { wrap } from './utils.js';
|
|
5
|
+
export function plan(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('plan')
|
|
8
|
+
.description('High level wrapper for terraform plan. Compliments deploy command: if you plan to deploy env with envctl deploy,'
|
|
9
|
+
+ ' 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)')
|
|
15
|
+
.allowUnknownOption(true)
|
|
16
|
+
.argument('[args...]')
|
|
17
|
+
.action(wrap(handler));
|
|
18
|
+
}
|
|
19
|
+
async function handler(tfArgs, options) {
|
|
20
|
+
const envDto = await parseEnvDto(options);
|
|
21
|
+
await envCtl.plan(envDto, tfArgs, options.cwd);
|
|
22
|
+
}
|
package/dist/commands/utils.js
CHANGED
|
@@ -25,14 +25,12 @@ export function wrap(callable) {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
export async function promptYesNo(message, defaultValue = false) {
|
|
28
|
-
const { answer } = await inquirer.prompt([
|
|
29
|
-
{
|
|
28
|
+
const { answer } = await inquirer.prompt([{
|
|
30
29
|
type: 'confirm',
|
|
31
30
|
name: 'answer',
|
|
32
31
|
message,
|
|
33
32
|
default: defaultValue,
|
|
34
|
-
}
|
|
35
|
-
]);
|
|
33
|
+
}]);
|
|
36
34
|
return answer;
|
|
37
35
|
}
|
|
38
36
|
export function ensureKind(kind, cwd) {
|
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 } from './commands/index.js';
|
|
5
|
+
import { configure, deleteIt, deploy, plan } 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();
|
|
@@ -14,4 +14,5 @@ program
|
|
|
14
14
|
configure(program);
|
|
15
15
|
deploy(program);
|
|
16
16
|
deleteIt(program);
|
|
17
|
+
plan(program);
|
|
17
18
|
program.parse(process.argv);
|
package/dist/service/EnvCtl.js
CHANGED
|
@@ -8,13 +8,25 @@ export class EnvCtl {
|
|
|
8
8
|
this.envApi = envApi;
|
|
9
9
|
this.terraformAdapter = terraformAdapter;
|
|
10
10
|
}
|
|
11
|
+
key(project, env) {
|
|
12
|
+
return project ? `${project}-${env}` : env;
|
|
13
|
+
}
|
|
14
|
+
async plan(envDto, tfArgs, cwd) {
|
|
15
|
+
const key = this.key(envDto.project, envDto.env);
|
|
16
|
+
console.log(`Check if env ${key} already exists`);
|
|
17
|
+
const env = await this.envApi.get(key);
|
|
18
|
+
if (env !== null) {
|
|
19
|
+
this.checkEnv(env, envDto);
|
|
20
|
+
}
|
|
21
|
+
await this.terraformAdapter.init(key, cwd);
|
|
22
|
+
await this.terraformAdapter.plan(envDto, tfArgs, cwd);
|
|
23
|
+
}
|
|
11
24
|
async deploy(envDto, tfArgs, cwd) {
|
|
12
|
-
const key =
|
|
25
|
+
const key = this.key(envDto.project, envDto.env);
|
|
13
26
|
console.log(`Check if env ${key} already exists`);
|
|
14
27
|
const env = await this.envApi.get(key);
|
|
15
28
|
if (env === null) {
|
|
16
29
|
console.log(`Env ${key} does not exist, creating it`);
|
|
17
|
-
console.log('First run terraform init to download providers, this doesn\'t create any resources in AWS even S3 object');
|
|
18
30
|
await this.terraformAdapter.init(key, cwd);
|
|
19
31
|
console.log('Creating env tracking record in DynamoDB');
|
|
20
32
|
const { owner, size, type, kind } = envDto;
|
|
@@ -22,8 +34,27 @@ export class EnvCtl {
|
|
|
22
34
|
await this.envApi.create(createEnv);
|
|
23
35
|
return await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
24
36
|
}
|
|
37
|
+
this.checkEnv(env, envDto);
|
|
38
|
+
switch (env.status) {
|
|
39
|
+
case EnvStatus.Creating:
|
|
40
|
+
case EnvStatus.Updating: {
|
|
41
|
+
const answerYes = await promptYesNo(`Env status is ${env.status}, likely to be an error from a previous run\n`
|
|
42
|
+
+ 'Do you want to proceed with deployment?');
|
|
43
|
+
if (answerYes) {
|
|
44
|
+
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case EnvStatus.Active: {
|
|
49
|
+
console.log('Env status is ACTIVE\nWill lock for update and run terraform apply (to update resources)');
|
|
50
|
+
await this.envApi.lockForUpdate(key);
|
|
51
|
+
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
checkEnv(env, envDto) {
|
|
25
56
|
if (env.ephemeral) {
|
|
26
|
-
throw new KnownException(`Attempted to convert ephemeral env
|
|
57
|
+
throw new KnownException(`Attempted to convert ephemeral env to non-ephemeral`);
|
|
27
58
|
}
|
|
28
59
|
if (env.owner !== envDto.owner) {
|
|
29
60
|
throw new KnownException(`Can not change env owner ${env.owner} -> ${envDto.owner}`);
|
|
@@ -37,33 +68,14 @@ export class EnvCtl {
|
|
|
37
68
|
if (env.kind !== envDto.kind) {
|
|
38
69
|
throw new KnownException(`Can not change env kind ${env.kind} -> ${envDto.kind}`);
|
|
39
70
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const answerYes = await promptYesNo(`Env status is ${env.status}, likely to be an error from a previous run\n
|
|
44
|
-
Do you want to proceed with deployment? (y/n)`);
|
|
45
|
-
if (answerYes) {
|
|
46
|
-
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
47
|
-
}
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
case EnvStatus.Active: {
|
|
51
|
-
console.log('Env status is ACTIVE\nWill lock for update and run terraform apply (to update resources)');
|
|
52
|
-
await this.envApi.lockForUpdate(key);
|
|
53
|
-
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
case EnvStatus.Deleting: {
|
|
57
|
-
const time = EnvSizeAvgTime[env.size];
|
|
58
|
-
throw new KnownException(`Env status is DELETING, please wait (~${time} min)`);
|
|
59
|
-
}
|
|
60
|
-
default:
|
|
61
|
-
throw new KnownException(`Unsupported environment status: ${env.status}`);
|
|
71
|
+
if (env.status === EnvStatus.Deleting) {
|
|
72
|
+
const time = EnvSizeAvgTime[env.size];
|
|
73
|
+
throw new KnownException(`Env status is DELETING, please wait (~${time} min)`);
|
|
62
74
|
}
|
|
63
75
|
}
|
|
64
|
-
async runDeploy(key,
|
|
76
|
+
async runDeploy(key, envDto, tfArgs, cwd) {
|
|
65
77
|
console.log('Deploying resources');
|
|
66
|
-
await this.terraformAdapter.deploy(
|
|
78
|
+
await this.terraformAdapter.deploy(envDto, tfArgs, cwd);
|
|
67
79
|
console.log('Activating env (to finish creation)');
|
|
68
80
|
await this.envApi.activate(key);
|
|
69
81
|
console.log('Lock env to run db evolution');
|
|
@@ -72,14 +84,14 @@ export class EnvCtl {
|
|
|
72
84
|
await this.envApi.activate(key);
|
|
73
85
|
}
|
|
74
86
|
async delete(envName, project) {
|
|
75
|
-
const key = project
|
|
87
|
+
const key = this.key(project, envName);
|
|
76
88
|
console.log(`Retrieve env`);
|
|
77
89
|
const env = await this.envApi.get(key);
|
|
78
90
|
if (env === null) {
|
|
79
91
|
throw new KnownException(`Environment ${key} does not exist`);
|
|
80
92
|
}
|
|
81
93
|
if (env.status === EnvStatus.Creating) {
|
|
82
|
-
const answerYes = await promptYesNo('Environment is still being created.\nDo you want to delete it?
|
|
94
|
+
const answerYes = await promptYesNo('Environment is still being created.\nDo you want to delete it?');
|
|
83
95
|
if (!answerYes) {
|
|
84
96
|
throw new KnownException('Aborting env deletion');
|
|
85
97
|
}
|
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.
|
|
4
|
+
"version": "0.12.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-plan": "tsc --sourceMap true && npm run run -- plan --size min --cwd ../tt-core",
|
|
30
31
|
"run-deploy": "tsc --sourceMap true && npm run run -- deploy --size min --cwd ../tt-core",
|
|
31
32
|
"run-delete": "tsc --sourceMap true && npm run run -- delete"
|
|
32
33
|
},
|