@agilecustoms/envctl 1.6.1 → 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.
@@ -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 = readline.createInterface({
21
- input: process.stdin,
22
- output: process.stdout,
23
- });
24
- rl.on('line', (line) => {
25
- if (in_scanner) {
26
- in_scanner(line);
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
- if (out_scanner)
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
- rl.close();
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
- rl.close();
105
+ cleanup();
71
106
  const code = err?.errno ?? 1;
72
107
  reject(new ProcessException(code, err.message));
73
108
  });
@@ -42,6 +42,6 @@ export class HttpClient {
42
42
  if (response.status === 422) {
43
43
  throw new BusinessException(message);
44
44
  }
45
- throw new HttpException(response.status, message);
45
+ throw new HttpException(message);
46
46
  }
47
47
  }
@@ -1,3 +1,2 @@
1
1
  export { EnvApiClient } from './EnvApiClient.js';
2
2
  export { HttpClient } from './HttpClient.js';
3
- export { TerraformAdapter } from './TerraformAdapter.js';
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, TerraformAdapter } from './client/index.js';
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 };
@@ -11,34 +11,36 @@ export class AbortedException extends Error {
11
11
  }
12
12
  }
13
13
  export class HttpException extends Error {
14
- statusCode;
15
- responseBody;
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(422, message);
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(404, message);
27
+ super(message);
34
28
  this.name = 'NotFoundException';
35
29
  }
36
30
  }
37
31
  export class ProcessException extends Error {
38
32
  code;
39
- constructor(code, message) {
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,4 +1,3 @@
1
- import { TerraformAdapter } from '../client/index.js';
2
1
  export class BaseService {
3
2
  terraformAdapter;
4
3
  constructor(terraformAdapter) {
@@ -1,5 +1,5 @@
1
1
  import { Cli } from '../client/Cli.js';
2
- import { EnvApiClient, TerraformAdapter } from '../client/index.js';
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
- await this.lockTerraform(env, env === null);
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 { TerraformAdapter } from '../client/index.js';
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 { AbortedException, KnownException, ProcessException } from '../exceptions.js';
5
- import { LocalStateService } from '../service/LocalStateService.js';
6
- const MAX_ATTEMPTS = 2;
7
- const RETRYABLE_ERRORS = [
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 = 1) {
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, attemptNo = 1) {
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.apply(args, attemptNo + 1);
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.apply(args, attemptNo + 1);
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)) {
@@ -1,2 +1,3 @@
1
1
  export { EnvService } from './EnvService.js';
2
2
  export { ConfigService } from './ConfigService.js';
3
+ export { TerraformAdapter } from './TerraformAdapter.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilecustoms/envctl",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "node.js CLI client for manage environments",
5
5
  "keywords": [
6
6
  "terraform wrapper",