@agilecustoms/envctl 1.6.0 → 1.7.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/LICENSE +6 -0
- package/dist/client/Cli.js +55 -20
- package/dist/client/HttpClient.js +1 -1
- package/dist/client/index.js +0 -1
- package/dist/container.js +3 -3
- package/dist/exceptions.js +13 -11
- package/dist/service/BaseService.js +0 -1
- package/dist/service/EnvService.js +4 -3
- package/dist/service/LogService.js +1 -1
- package/dist/{client → service}/TerraformAdapter.js +36 -13
- package/dist/service/index.js +1 -0
- package/package.json +16 -7
package/LICENSE
ADDED
package/dist/client/Cli.js
CHANGED
|
@@ -5,34 +5,59 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { Readable } from 'node:stream';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { clearTimeout } from 'node:timers';
|
|
8
9
|
import path from 'path';
|
|
9
10
|
import * as readline from 'readline';
|
|
10
11
|
import inquirer from 'inquirer';
|
|
11
|
-
import { ProcessException } from '../exceptions.js';
|
|
12
|
+
import { ProcessException, TimeoutException } from '../exceptions.js';
|
|
13
|
+
export const NO_TIMEOUT = 0;
|
|
12
14
|
export class Cli {
|
|
13
|
-
async run(command, args, out_scanner, in_scanner) {
|
|
15
|
+
async run(command, args, timeoutMs = NO_TIMEOUT, out_scanner, in_scanner) {
|
|
16
|
+
const needsInteractive = !!in_scanner;
|
|
14
17
|
const spawnOptions = {
|
|
15
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
18
|
+
stdio: [needsInteractive ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
16
19
|
};
|
|
17
20
|
const child = spawn(command, args, spawnOptions);
|
|
18
21
|
child.stdout.setEncoding('utf8');
|
|
19
22
|
child.stderr.setEncoding('utf8');
|
|
20
|
-
const rl =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const rl = needsInteractive
|
|
24
|
+
? readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout,
|
|
27
|
+
})
|
|
28
|
+
: null;
|
|
29
|
+
let timeoutId = null;
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
if (timeoutMs > 0) {
|
|
32
|
+
timeoutId = setTimeout(() => {
|
|
33
|
+
timedOut = true;
|
|
34
|
+
child.kill('SIGTERM');
|
|
35
|
+
timeoutId = null;
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
}
|
|
38
|
+
function clearTimeoutIfNeeded() {
|
|
39
|
+
if (timeoutId) {
|
|
40
|
+
clearTimeout(timeoutId);
|
|
41
|
+
timeoutId = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const handlers = [];
|
|
45
|
+
function on(sig) {
|
|
46
|
+
const h = () => child.kill(sig);
|
|
47
|
+
process.on(sig, h);
|
|
48
|
+
handlers.push([sig, h]);
|
|
49
|
+
}
|
|
50
|
+
on('SIGINT');
|
|
51
|
+
on('SIGTERM');
|
|
52
|
+
on('SIGHUP');
|
|
53
|
+
rl?.on('line', (line) => {
|
|
54
|
+
in_scanner?.(line);
|
|
55
|
+
if (child.stdin.writable) {
|
|
56
|
+
child.stdin.write(line + '\n');
|
|
27
57
|
}
|
|
28
|
-
child.stdin.write(line + '\n');
|
|
29
|
-
});
|
|
30
|
-
rl.on('SIGINT', () => {
|
|
31
|
-
child.kill('SIGINT');
|
|
32
58
|
});
|
|
33
59
|
function processLine(line) {
|
|
34
|
-
|
|
35
|
-
out_scanner(line);
|
|
60
|
+
out_scanner?.(line);
|
|
36
61
|
console.log(line);
|
|
37
62
|
}
|
|
38
63
|
let buffer = '';
|
|
@@ -50,12 +75,22 @@ export class Cli {
|
|
|
50
75
|
child.stderr.on('data', (data) => {
|
|
51
76
|
errorBuffer += data;
|
|
52
77
|
});
|
|
78
|
+
function cleanup() {
|
|
79
|
+
for (const [sig, h] of handlers)
|
|
80
|
+
process.off(sig, h);
|
|
81
|
+
clearTimeoutIfNeeded();
|
|
82
|
+
rl?.close();
|
|
83
|
+
}
|
|
53
84
|
return new Promise((resolve, reject) => {
|
|
54
|
-
child.on('close', (code) => {
|
|
55
|
-
|
|
85
|
+
child.on('close', (code, signal) => {
|
|
86
|
+
cleanup();
|
|
56
87
|
if (buffer !== '') {
|
|
57
88
|
processLine(buffer);
|
|
58
89
|
}
|
|
90
|
+
if (timedOut) {
|
|
91
|
+
reject(new TimeoutException(`Process killed after timeout of ${timeoutMs}ms`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
59
94
|
if (code === 0) {
|
|
60
95
|
if (errorBuffer) {
|
|
61
96
|
console.warn('Process completed successfully, but there were errors:\n' + errorBuffer);
|
|
@@ -63,11 +98,11 @@ export class Cli {
|
|
|
63
98
|
resolve();
|
|
64
99
|
}
|
|
65
100
|
else {
|
|
66
|
-
reject(new ProcessException(code, errorBuffer));
|
|
101
|
+
reject(new ProcessException(code, errorBuffer, signal));
|
|
67
102
|
}
|
|
68
103
|
});
|
|
69
104
|
child.on('error', (err) => {
|
|
70
|
-
|
|
105
|
+
cleanup();
|
|
71
106
|
const code = err?.errno ?? 1;
|
|
72
107
|
reject(new ProcessException(code, err.message));
|
|
73
108
|
});
|
package/dist/client/index.js
CHANGED
package/dist/container.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { S3Backend } from './backend/S3Backend.js';
|
|
2
2
|
import { Cli } from './client/Cli.js';
|
|
3
|
-
import { EnvApiClient, HttpClient
|
|
4
|
-
import { ConfigService, EnvService } from './service/index.js';
|
|
3
|
+
import { EnvApiClient, HttpClient } from './client/index.js';
|
|
4
|
+
import { ConfigService, EnvService, TerraformAdapter } from './service/index.js';
|
|
5
5
|
import { LocalStateService } from './service/LocalStateService.js';
|
|
6
6
|
import { LogService } from './service/LogService.js';
|
|
7
7
|
const cli = new Cli();
|
|
@@ -12,7 +12,7 @@ const backends = [
|
|
|
12
12
|
const httpClient = new HttpClient();
|
|
13
13
|
const envApiClient = new EnvApiClient(httpClient);
|
|
14
14
|
const localStateService = new LocalStateService();
|
|
15
|
-
const terraformAdapter = new TerraformAdapter(cli, localStateService, backends);
|
|
15
|
+
const terraformAdapter = new TerraformAdapter(Date.now, cli, localStateService, backends);
|
|
16
16
|
const envService = new EnvService(cli, envApiClient, terraformAdapter);
|
|
17
17
|
const logService = new LogService(cli, envApiClient, terraformAdapter);
|
|
18
18
|
export { configService, envService, logService };
|
package/dist/exceptions.js
CHANGED
|
@@ -11,34 +11,36 @@ export class AbortedException extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
export class HttpException extends Error {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
constructor(statusCode, responseBody) {
|
|
17
|
-
super(`Received ${statusCode} response`);
|
|
18
|
-
this.statusCode = statusCode;
|
|
19
|
-
this.responseBody = responseBody;
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
20
16
|
this.name = 'HttpException';
|
|
21
17
|
}
|
|
22
18
|
}
|
|
23
19
|
export class BusinessException extends HttpException {
|
|
24
|
-
message;
|
|
25
20
|
constructor(message) {
|
|
26
|
-
super(
|
|
27
|
-
this.message = message;
|
|
21
|
+
super(message);
|
|
28
22
|
this.name = 'BusinessException';
|
|
29
23
|
}
|
|
30
24
|
}
|
|
31
25
|
export class NotFoundException extends HttpException {
|
|
32
26
|
constructor(message) {
|
|
33
|
-
super(
|
|
27
|
+
super(message);
|
|
34
28
|
this.name = 'NotFoundException';
|
|
35
29
|
}
|
|
36
30
|
}
|
|
37
31
|
export class ProcessException extends Error {
|
|
38
32
|
code;
|
|
39
|
-
|
|
33
|
+
signal;
|
|
34
|
+
constructor(code, message, signal = null) {
|
|
40
35
|
super(message);
|
|
41
36
|
this.code = code;
|
|
37
|
+
this.signal = signal;
|
|
42
38
|
this.name = 'ProcessException';
|
|
43
39
|
}
|
|
44
40
|
}
|
|
41
|
+
export class TimeoutException extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'TimeoutException';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cli } from '../client/Cli.js';
|
|
2
|
-
import { EnvApiClient
|
|
2
|
+
import { EnvApiClient } from '../client/index.js';
|
|
3
3
|
import { KnownException } from '../exceptions.js';
|
|
4
4
|
import { EnvStatus } from '../model/index.js';
|
|
5
5
|
import { BaseService } from './BaseService.js';
|
|
@@ -68,7 +68,8 @@ export class EnvService extends BaseService {
|
|
|
68
68
|
this.checkKind(env);
|
|
69
69
|
this.handleDeleteStatuses(env);
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
const newEnv = env === null;
|
|
72
|
+
await this.lockTerraform(env, newEnv);
|
|
72
73
|
await this.terraformAdapter.plan(env, tfArgs);
|
|
73
74
|
}
|
|
74
75
|
async createEphemeral(key) {
|
|
@@ -158,7 +159,7 @@ export class EnvService extends BaseService {
|
|
|
158
159
|
}
|
|
159
160
|
async runDeploy(env, tfArgs) {
|
|
160
161
|
console.log('Deploying resources');
|
|
161
|
-
await this.terraformAdapter.apply(tfArgs);
|
|
162
|
+
await this.terraformAdapter.apply(tfArgs, env.ttl);
|
|
162
163
|
console.log('Activating env (to finish creation)');
|
|
163
164
|
await this.envApi.activate(env);
|
|
164
165
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import {} from '../client/index.js';
|
|
3
3
|
import { KnownException } from '../exceptions.js';
|
|
4
4
|
import { BaseService } from './BaseService.js';
|
|
5
5
|
export class LogService extends BaseService {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
const
|
|
4
|
+
import { NO_TIMEOUT } from '../client/Cli.js';
|
|
5
|
+
import { AbortedException, KnownException, ProcessException, TimeoutException } from '../exceptions.js';
|
|
6
|
+
import { LocalStateService } from './LocalStateService.js';
|
|
7
|
+
export const MAX_ATTEMPTS = 2;
|
|
8
|
+
export const RETRYABLE_ERRORS = [
|
|
8
9
|
'ConcurrentModificationException',
|
|
9
10
|
'public policies are blocked by the BlockPublicPolicy block public access setting',
|
|
10
11
|
'operation error Lambda: AddPermission, https response error StatusCode: 404',
|
|
@@ -14,11 +15,13 @@ function hash(data) {
|
|
|
14
15
|
return createHash('sha256').update(data).digest('hex');
|
|
15
16
|
}
|
|
16
17
|
export class TerraformAdapter {
|
|
18
|
+
now;
|
|
17
19
|
cli;
|
|
18
20
|
localStateService;
|
|
19
21
|
backends;
|
|
20
22
|
backend = undefined;
|
|
21
|
-
constructor(cli, localStateService, backends) {
|
|
23
|
+
constructor(now, cli, localStateService, backends) {
|
|
24
|
+
this.now = now;
|
|
22
25
|
this.cli = cli;
|
|
23
26
|
this.localStateService = localStateService;
|
|
24
27
|
this.backends = backends.reduce((acc, backend) => {
|
|
@@ -191,9 +194,9 @@ export class TerraformAdapter {
|
|
|
191
194
|
if (env) {
|
|
192
195
|
args = this.tfArgs(env, args);
|
|
193
196
|
}
|
|
194
|
-
await this._plan(args, onDemandVars);
|
|
197
|
+
await this._plan(args, onDemandVars, 1);
|
|
195
198
|
}
|
|
196
|
-
async _plan(args, onDemandVars, attemptNo
|
|
199
|
+
async _plan(args, onDemandVars, attemptNo) {
|
|
197
200
|
let inputTfVariable = false;
|
|
198
201
|
let tfVarName = '';
|
|
199
202
|
function out_scanner(line) {
|
|
@@ -214,7 +217,7 @@ export class TerraformAdapter {
|
|
|
214
217
|
}
|
|
215
218
|
console.log('Running: terraform plan', ...args, '\n');
|
|
216
219
|
try {
|
|
217
|
-
await this.cli.run('terraform', ['plan', ...args], out_scanner, in_scanner);
|
|
220
|
+
await this.cli.run('terraform', ['plan', ...args], NO_TIMEOUT, out_scanner, in_scanner);
|
|
218
221
|
}
|
|
219
222
|
catch (error) {
|
|
220
223
|
if (!(error instanceof ProcessException)) {
|
|
@@ -233,11 +236,31 @@ export class TerraformAdapter {
|
|
|
233
236
|
throw new KnownException(`terraform plan failed with code ${error.code}:\n${error.message}`, { cause: error });
|
|
234
237
|
}
|
|
235
238
|
}
|
|
236
|
-
async apply(args,
|
|
239
|
+
async apply(args, ttl) {
|
|
237
240
|
console.log('Running: terraform apply', ...args, '\n');
|
|
241
|
+
try {
|
|
242
|
+
await this._apply(args, ttl, 1);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof TimeoutException) {
|
|
246
|
+
throw new KnownException('Reached environment ttl while running \'terraform apply\' command\n'
|
|
247
|
+
+ 'Command was stopped to avoid race condition with deletion process\n'
|
|
248
|
+
+ 'Please wait env deletion', { cause: error });
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async _apply(args, ttl, attemptNo) {
|
|
238
254
|
this.printTime();
|
|
255
|
+
const nowUtcSeconds = Math.floor(this.now() / 1000);
|
|
256
|
+
const gracePeriod = 10;
|
|
257
|
+
const timeout = (ttl - nowUtcSeconds - gracePeriod) * 1000;
|
|
258
|
+
if (timeout <= 0) {
|
|
259
|
+
throw new KnownException('TTL expired before terraform apply could start');
|
|
260
|
+
}
|
|
261
|
+
console.debug('timeout: ', timeout);
|
|
239
262
|
try {
|
|
240
|
-
await this.cli.run('terraform', ['apply', ...args]);
|
|
263
|
+
await this.cli.run('terraform', ['apply', ...args], timeout);
|
|
241
264
|
this.printTime();
|
|
242
265
|
}
|
|
243
266
|
catch (error) {
|
|
@@ -246,13 +269,13 @@ export class TerraformAdapter {
|
|
|
246
269
|
}
|
|
247
270
|
if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
|
|
248
271
|
console.warn(`Retrying terraform apply due to error: ${error.message}`);
|
|
249
|
-
return this.
|
|
272
|
+
return this._apply(args, ttl, attemptNo + 1);
|
|
250
273
|
}
|
|
251
274
|
const lockId = this.lockId(error, attemptNo);
|
|
252
275
|
if (lockId) {
|
|
253
276
|
await this.promptUnlock(lockId);
|
|
254
277
|
console.info('State unlocked, retrying terraform apply');
|
|
255
|
-
return this.
|
|
278
|
+
return this._apply(args, ttl, attemptNo + 1);
|
|
256
279
|
}
|
|
257
280
|
throw new KnownException(`terraform apply failed with code ${error.code}:\n${error.message}`, { cause: error });
|
|
258
281
|
}
|
|
@@ -270,7 +293,7 @@ export class TerraformAdapter {
|
|
|
270
293
|
};
|
|
271
294
|
console.log('Running: terraform destroy -auto-approve', ...args, '\n');
|
|
272
295
|
try {
|
|
273
|
-
await this.cli.run('terraform', ['destroy', '-auto-approve', ...args], scanner);
|
|
296
|
+
await this.cli.run('terraform', ['destroy', '-auto-approve', ...args], NO_TIMEOUT, scanner);
|
|
274
297
|
}
|
|
275
298
|
catch (error) {
|
|
276
299
|
if (!(error instanceof ProcessException)) {
|
package/dist/service/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilecustoms/envctl",
|
|
3
|
+
"version": "1.7.0",
|
|
3
4
|
"description": "node.js CLI client for manage environments",
|
|
4
|
-
"
|
|
5
|
-
|
|
5
|
+
"keywords": [
|
|
6
|
+
"terraform wrapper",
|
|
7
|
+
"auto deletion"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/agilecustoms/envctl",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/agilecustoms/envctl/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/agilecustoms/envctl.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"author": "Alexey Chekulaev <chekulaevalexey@gmail.com> (https://github.com/laxa1986)",
|
|
6
19
|
"type": "module",
|
|
7
20
|
"engines": {
|
|
8
21
|
"node": ">=22.0.0"
|
|
9
22
|
},
|
|
23
|
+
"private": false,
|
|
10
24
|
"bin": {
|
|
11
25
|
"envctl": "dist/index.js"
|
|
12
26
|
},
|
|
@@ -14,11 +28,6 @@
|
|
|
14
28
|
"dist/",
|
|
15
29
|
"package.json"
|
|
16
30
|
],
|
|
17
|
-
"repository": {
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/agilecustoms/envctl.git"
|
|
20
|
-
},
|
|
21
|
-
"license": "UNLICENSED",
|
|
22
31
|
"_comment0": "to run via `npx` you must use `name` (with scope) and have at least one entry in `bin`. Key does not matter when run via `npx`",
|
|
23
32
|
"_comment1": "keys become CLI apps (symlinks) when install globally. ex: npm install -g @agilecustoms/envctl; ls $(which alexc-blah) >> lrwxr-xr-x 1 alexc admin 62 Dec 30 14:29 /opt/homebrew/bin/alexc-blah -> ../lib/node_modules/@agilecustoms/envctl-client/dist/index.js",
|
|
24
33
|
"scripts": {
|