@agilecustoms/envctl 1.6.1 → 1.8.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,62 @@ 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
+ import { logger } from '../logger.js';
14
+ export const NO_TIMEOUT = 0;
12
15
  export class Cli {
13
- async run(command, args, out_scanner, in_scanner) {
16
+ constructor() {
17
+ }
18
+ async run(command, args, timeoutMs = NO_TIMEOUT, out_scanner, in_scanner) {
19
+ const needsInteractive = !!in_scanner;
14
20
  const spawnOptions = {
15
- stdio: ['pipe', 'pipe', 'pipe'],
21
+ stdio: [needsInteractive ? 'pipe' : 'ignore', 'pipe', 'pipe'],
16
22
  };
17
23
  const child = spawn(command, args, spawnOptions);
18
24
  child.stdout.setEncoding('utf8');
19
25
  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);
26
+ const rl = needsInteractive
27
+ ? readline.createInterface({
28
+ input: process.stdin,
29
+ output: process.stdout,
30
+ })
31
+ : null;
32
+ let timeoutId = null;
33
+ let timedOut = false;
34
+ if (timeoutMs > 0) {
35
+ timeoutId = setTimeout(() => {
36
+ timedOut = true;
37
+ child.kill('SIGTERM');
38
+ timeoutId = null;
39
+ }, timeoutMs);
40
+ }
41
+ function clearTimeoutIfNeeded() {
42
+ if (timeoutId) {
43
+ clearTimeout(timeoutId);
44
+ timeoutId = null;
45
+ }
46
+ }
47
+ const handlers = [];
48
+ function on(sig) {
49
+ const h = () => child.kill(sig);
50
+ process.on(sig, h);
51
+ handlers.push([sig, h]);
52
+ }
53
+ on('SIGINT');
54
+ on('SIGTERM');
55
+ on('SIGHUP');
56
+ rl?.on('line', (line) => {
57
+ in_scanner?.(line);
58
+ if (child.stdin.writable) {
59
+ child.stdin.write(line + '\n');
27
60
  }
28
- child.stdin.write(line + '\n');
29
- });
30
- rl.on('SIGINT', () => {
31
- child.kill('SIGINT');
32
61
  });
33
62
  function processLine(line) {
34
- if (out_scanner)
35
- out_scanner(line);
63
+ out_scanner?.(line);
36
64
  console.log(line);
37
65
  }
38
66
  let buffer = '';
@@ -50,24 +78,34 @@ export class Cli {
50
78
  child.stderr.on('data', (data) => {
51
79
  errorBuffer += data;
52
80
  });
81
+ function cleanup() {
82
+ for (const [sig, h] of handlers)
83
+ process.off(sig, h);
84
+ clearTimeoutIfNeeded();
85
+ rl?.close();
86
+ }
53
87
  return new Promise((resolve, reject) => {
54
- child.on('close', (code) => {
55
- rl.close();
88
+ child.on('close', (code, signal) => {
89
+ cleanup();
56
90
  if (buffer !== '') {
57
91
  processLine(buffer);
58
92
  }
93
+ if (timedOut) {
94
+ reject(new TimeoutException(`Process killed after timeout of ${timeoutMs}ms`));
95
+ return;
96
+ }
59
97
  if (code === 0) {
60
98
  if (errorBuffer) {
61
- console.warn('Process completed successfully, but there were errors:\n' + errorBuffer);
99
+ logger.warn('Process completed successfully, but there were errors:\n' + errorBuffer);
62
100
  }
63
101
  resolve();
64
102
  }
65
103
  else {
66
- reject(new ProcessException(code, errorBuffer));
104
+ reject(new ProcessException(code, errorBuffer, signal));
67
105
  }
68
106
  });
69
107
  child.on('error', (err) => {
70
- rl.close();
108
+ cleanup();
71
109
  const code = err?.errno ?? 1;
72
110
  reject(new ProcessException(code, err.message));
73
111
  });
@@ -1,4 +1,5 @@
1
1
  import { BusinessException, KnownException, NotFoundException } from '../exceptions.js';
2
+ import { logger } from '../logger.js';
2
3
  import { EnvStatus } from '../model/index.js';
3
4
  import { HttpClient, toUrl } from './HttpClient.js';
4
5
  export class EnvApiClient {
@@ -76,7 +77,7 @@ export class EnvApiClient {
76
77
  env.status = result.status;
77
78
  }
78
79
  async delete(key, force = false) {
79
- console.log('Sending delete command');
80
+ logger.info('Sending delete command');
80
81
  let result;
81
82
  try {
82
83
  result = await this.httpClient.fetch(`/ci/env/${key}?force=${force}`, {
@@ -89,7 +90,7 @@ export class EnvApiClient {
89
90
  }
90
91
  throw error;
91
92
  }
92
- console.log(result.message);
93
+ logger.info(result.message);
93
94
  }
94
95
  async getLogs(key) {
95
96
  let result;
@@ -1,4 +1,5 @@
1
1
  import { BusinessException, HttpException, NotFoundException } from '../exceptions.js';
2
+ import { logger } from '../logger.js';
2
3
  const HOST = 'cli.maintenance.agilecustoms.com';
3
4
  export function toUrl(path) {
4
5
  return `https://${HOST}/env-api${path}`;
@@ -19,6 +20,10 @@ export class HttpClient {
19
20
  }
20
21
  async fetch(path, options = {}) {
21
22
  const url = toUrl(path);
23
+ if (!options.method) {
24
+ options.method = 'GET';
25
+ }
26
+ logger.debug(`--> ${options.method} ${url} ${JSON.stringify(options)}`);
22
27
  let response;
23
28
  try {
24
29
  response = await fetch(url, options);
@@ -42,6 +47,6 @@ export class HttpClient {
42
47
  if (response.status === 422) {
43
48
  throw new BusinessException(message);
44
49
  }
45
- throw new HttpException(response.status, message);
50
+ throw new HttpException(message);
46
51
  }
47
52
  }
@@ -1,3 +1,2 @@
1
1
  export { EnvApiClient } from './EnvApiClient.js';
2
2
  export { HttpClient } from './HttpClient.js';
3
- export { TerraformAdapter } from './TerraformAdapter.js';
@@ -3,5 +3,6 @@ export const _keys = {
3
3
  FORCE: 'Force deletion without confirmation',
4
4
  KEY: 'Environment name/identifier - taken from remote stake key, must be unique for a given customer',
5
5
  KIND: 'Environment kind: complete project (default) or some slice such as tt-core + tt-web',
6
- OWNER: 'Environment owner (GH username)'
6
+ OWNER: 'Environment owner (GH username)',
7
+ VERBOSE: 'Verbose output (w/ debug logs)'
7
8
  };
@@ -1,24 +1,21 @@
1
1
  import { ExitPromptError } from '@inquirer/core';
2
2
  import { AbortedException, KnownException } from '../exceptions.js';
3
+ import { logger } from '../logger.js';
3
4
  export function wrap(callable) {
4
5
  return async (...args) => {
5
- let result;
6
6
  try {
7
- result = await callable(...args);
7
+ await callable(...args);
8
8
  }
9
9
  catch (error) {
10
10
  if (error instanceof KnownException) {
11
- console.error(error.message);
11
+ logger.error(error.message);
12
12
  }
13
13
  else if (error instanceof AbortedException || error instanceof ExitPromptError) {
14
14
  }
15
15
  else {
16
- console.error('Unknown error:', error);
16
+ logger.error('Unknown error: ' + error);
17
17
  }
18
18
  process.exit(1);
19
19
  }
20
- if (result !== undefined) {
21
- console.log(result);
22
- }
23
20
  };
24
21
  }
@@ -1,11 +1,13 @@
1
1
  import { Command } from 'commander';
2
2
  import inquirer from 'inquirer';
3
3
  import { configService } from '../container.js';
4
+ import { _keys } from './_keys.js';
4
5
  import { wrap } from './_utils.js';
5
6
  export function configure(program) {
6
7
  program
7
8
  .command('configure')
8
9
  .description('Configure user settings on your local machine')
10
+ .option('-v, --verbose', _keys.VERBOSE)
9
11
  .action(wrap(handler));
10
12
  }
11
13
  async function handler() {
@@ -7,6 +7,7 @@ export function createEphemeral(program) {
7
7
  .command('create-ephemeral')
8
8
  .description('Create bare env w/o resources. Used in CI as first step just to pre-generate token for extension')
9
9
  .requiredOption('--key <key>', _keys.KEY)
10
+ .option('-v, --verbose', _keys.VERBOSE)
10
11
  .action(wrap(handler));
11
12
  }
12
13
  async function handler(options) {
@@ -9,6 +9,7 @@ export function deleteIt(program) {
9
9
  .option('--key <key>', _keys.KEY)
10
10
  .option('--force', _keys.FORCE)
11
11
  .option('--cwd <cwd>', _keys.CWD)
12
+ .option('-v, --verbose', _keys.VERBOSE)
12
13
  .action(wrap(handler));
13
14
  }
14
15
  async function handler(options) {
@@ -7,6 +7,7 @@ export function deploy(program) {
7
7
  .command('deploy')
8
8
  .description('Create new or update existing environment')
9
9
  .option('--cwd <cwd>', _keys.CWD)
10
+ .option('-v, --verbose', _keys.VERBOSE)
10
11
  .allowUnknownOption(true)
11
12
  .argument('[args...]')
12
13
  .action(wrap(handler));
@@ -11,6 +11,7 @@ export function destroy(program) {
11
11
  + ' Main use case - test deletion process, basically that you have enough permissions to delete resources')
12
12
  .option('--force', _keys.FORCE)
13
13
  .option('--cwd <cwd>', _keys.CWD)
14
+ .option('-v, --verbose', _keys.VERBOSE)
14
15
  .allowUnknownOption(true)
15
16
  .argument('[args...]')
16
17
  .action(wrap(handler));
@@ -8,6 +8,7 @@ export function logs(program) {
8
8
  .description('Get most recent env destroy logs')
9
9
  .option('--key <key>', _keys.KEY)
10
10
  .option('--cwd <cwd>', _keys.CWD)
11
+ .option('-v, --verbose', _keys.VERBOSE)
11
12
  .action(wrap(handler));
12
13
  }
13
14
  async function handler(options) {
@@ -8,6 +8,7 @@ export function plan(program) {
8
8
  .description('High level wrapper for terraform plan. Compliments deploy command: if you plan to deploy env with envctl deploy,'
9
9
  + ' then it is recommended to do plan with envctl plan to guarantee consistent behavior')
10
10
  .option('--cwd <cwd>', _keys.CWD)
11
+ .option('-v, --verbose', _keys.VERBOSE)
11
12
  .allowUnknownOption(true)
12
13
  .argument('[args...]')
13
14
  .action(wrap(handler));
@@ -8,6 +8,7 @@ export function status(program) {
8
8
  .description('Get env status')
9
9
  .option('--key <key>', _keys.KEY)
10
10
  .option('--cwd <cwd>', _keys.CWD)
11
+ .option('-v, --verbose', _keys.VERBOSE)
11
12
  .action(wrap(handler));
12
13
  }
13
14
  async function handler(options) {
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
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,36 @@
1
+ export class Logger {
2
+ now;
3
+ verbose;
4
+ buffer = [];
5
+ constructor(now, verbose) {
6
+ this.now = now;
7
+ this.verbose = verbose;
8
+ }
9
+ debug(msg) {
10
+ this.log('debug', msg);
11
+ }
12
+ info(msg) {
13
+ this.log('info', msg);
14
+ }
15
+ warn(msg) {
16
+ this.log('warn', msg);
17
+ }
18
+ error(msg) {
19
+ this.log('error', msg);
20
+ }
21
+ log(level, msg) {
22
+ const date = new Date(this.now()).toISOString();
23
+ const detailedMessage = `${date} [${level}] ${msg}`;
24
+ this.buffer.push(detailedMessage);
25
+ if (!this.verbose && level === 'debug') {
26
+ return;
27
+ }
28
+ const method = console[level];
29
+ method(this.verbose ? detailedMessage : `${msg}`);
30
+ }
31
+ getBuffer() {
32
+ return this.buffer;
33
+ }
34
+ }
35
+ const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
36
+ export const logger = new Logger(Date.now, verbose);
@@ -1,4 +1,4 @@
1
- import { TerraformAdapter } from '../client/index.js';
1
+ import { logger } from '../logger.js';
2
2
  export class BaseService {
3
3
  terraformAdapter;
4
4
  constructor(terraformAdapter) {
@@ -6,7 +6,7 @@ export class BaseService {
6
6
  }
7
7
  getKey(key) {
8
8
  if (!key) {
9
- console.log('Key is not provided, inferring from state file');
9
+ logger.info('Key is not provided, inferring from state file');
10
10
  key = this.terraformAdapter.getTerraformBackend().getKey();
11
11
  }
12
12
  return key;
@@ -1,6 +1,7 @@
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
+ import { logger } from '../logger.js';
4
5
  import { EnvStatus } from '../model/index.js';
5
6
  import { BaseService } from './BaseService.js';
6
7
  export const PLAN_FILE = '.terraform/envctl.plan';
@@ -14,13 +15,13 @@ export class EnvService extends BaseService {
14
15
  }
15
16
  async status(key) {
16
17
  key = this.getKey(key);
17
- console.log(`Retrieve env ${key}`);
18
+ logger.info(`Retrieve env ${key}`);
18
19
  const env = await this.envApi.get(key);
19
20
  if (env === null) {
20
- console.log(`Env ${key} does not exist`);
21
+ logger.info(`Env ${key} does not exist`);
21
22
  return;
22
23
  }
23
- console.log(`Env ${key} status: ${env.status}`);
24
+ logger.info(`Env ${key} status: ${env.status}`);
24
25
  if (env.status !== EnvStatus.Deleting && env.status !== EnvStatus.DeleteError) {
25
26
  const date = new Date(env.ttl * 1000);
26
27
  const formattedDate = date.toLocaleString(undefined, {
@@ -31,23 +32,23 @@ export class EnvService extends BaseService {
31
32
  second: '2-digit',
32
33
  hour12: false,
33
34
  });
34
- console.log(`Expires at ${formattedDate}`);
35
+ logger.info(`Expires at ${formattedDate}`);
35
36
  }
36
37
  if (env.kind) {
37
38
  const kind = this.cli.getKind();
38
39
  if (env.kind !== kind) {
39
- console.warn(`Env ${key} kind (dir-name): ${env.kind} - looks like this env was deployed from a different directory`);
40
+ logger.warn(`Env ${key} kind (dir-name): ${env.kind} - looks like this env was deployed from a different directory`);
40
41
  }
41
42
  }
42
43
  }
43
44
  async tryGetEnv(key) {
44
- console.log(`Check if env ${key} already exists`);
45
+ logger.info(`Check if env ${key} already exists`);
45
46
  const env = await this.envApi.get(key);
46
47
  if (env) {
47
- console.log(`Found ${key} env`);
48
+ logger.info(`Found ${key} env`);
48
49
  }
49
50
  else {
50
- console.log(`Env ${key} does not exist`);
51
+ logger.info(`Env ${key} does not exist`);
51
52
  }
52
53
  return env;
53
54
  }
@@ -68,7 +69,8 @@ export class EnvService extends BaseService {
68
69
  this.checkKind(env);
69
70
  this.handleDeleteStatuses(env);
70
71
  }
71
- await this.lockTerraform(env, env === null);
72
+ const newEnv = env === null;
73
+ await this.lockTerraform(env, newEnv);
72
74
  await this.terraformAdapter.plan(env, tfArgs);
73
75
  }
74
76
  async createEphemeral(key) {
@@ -76,7 +78,7 @@ export class EnvService extends BaseService {
76
78
  return await this.envApi.createEphemeral(createEnv);
77
79
  }
78
80
  async lockTerraform(env, newEnv = false) {
79
- console.log('Lock providers');
81
+ logger.info('Lock providers');
80
82
  const res = await this.terraformAdapter.lock(newEnv);
81
83
  if (env !== null) {
82
84
  if (res.lockFile) {
@@ -122,11 +124,11 @@ export class EnvService extends BaseService {
122
124
  let env = await this.tryGetEnv(key);
123
125
  if (env === null) {
124
126
  const kind = this.cli.getKind();
125
- console.log(`Inferred kind from directory name: ${kind}`);
127
+ logger.info(`Inferred kind from directory name: ${kind}`);
126
128
  const createEnv = { key, kind };
127
129
  await this.lockTerraform(createEnv, true);
128
130
  tfArgs = await this.ensurePlan(env, tfArgs);
129
- console.log('Creating env metadata');
131
+ logger.info('Creating env metadata');
130
132
  env = await this.envApi.create(createEnv);
131
133
  await this.runDeploy(env, tfArgs);
132
134
  return;
@@ -138,16 +140,16 @@ export class EnvService extends BaseService {
138
140
  const answerYes = await this.cli.promptYesNo(`Env status is ${status}, likely due to an error from a previous run\n
139
141
  Do you want to proceed with deployment?`);
140
142
  if (!answerYes) {
141
- console.log('Aborting deployment');
143
+ logger.info('Aborting deployment');
142
144
  return;
143
145
  }
144
146
  }
145
147
  if (status === EnvStatus.Init || status === EnvStatus.Active) {
146
- console.log(`Env status is ${status}`);
148
+ logger.info(`Env status is ${status}`);
147
149
  await this.lockTerraform(env);
148
150
  tfArgs = await this.ensurePlan(env, tfArgs);
149
151
  if (env.status == EnvStatus.Active) {
150
- console.log('Will lock for update and run terraform apply (to update resources)');
152
+ logger.info('Will lock for update and run terraform apply (to update resources)');
151
153
  }
152
154
  await this.envApi.lockForUpdate(env);
153
155
  await this.runDeploy(env, tfArgs);
@@ -157,9 +159,9 @@ export class EnvService extends BaseService {
157
159
  await this.runDeploy(env, tfArgs);
158
160
  }
159
161
  async runDeploy(env, tfArgs) {
160
- console.log('Deploying resources');
161
- await this.terraformAdapter.apply(tfArgs);
162
- console.log('Activating env (to finish creation)');
162
+ logger.info('Deploying resources');
163
+ await this.terraformAdapter.apply(tfArgs, env.ttl);
164
+ logger.info('Activating env (to finish creation)');
163
165
  await this.envApi.activate(env);
164
166
  }
165
167
  async delete(force, key) {
@@ -170,7 +172,7 @@ export class EnvService extends BaseService {
170
172
  const key = this.terraformAdapter.getTerraformBackend().getKey();
171
173
  const env = await this.get(key);
172
174
  if (env.status === EnvStatus.Init) {
173
- console.log(`Env ${env.key} status is INIT - no resources, nothing to destroy, just deleting metadata`);
175
+ logger.info(`Env ${env.key} status is INIT - no resources, nothing to destroy, just deleting metadata`);
174
176
  await this.envApi.delete(key, force);
175
177
  return;
176
178
  }
@@ -178,20 +180,20 @@ export class EnvService extends BaseService {
178
180
  throw new KnownException(`Env ${env.key} status is DELETING, please wait or re-run with --force`);
179
181
  }
180
182
  if (env.status === EnvStatus.Active) {
181
- console.log('Lock env to run destroy');
183
+ logger.info('Lock env to run destroy');
182
184
  await this.envApi.lockForUpdate(env);
183
185
  }
184
186
  await this.terraformAdapter.destroy(env, tfArgs, force);
185
187
  await this.envApi.delete(key, force);
186
- console.log('Please wait for ~30 sec before you can create env with same name');
188
+ logger.info('Please wait for ~30 sec before you can create env with same name');
187
189
  }
188
190
  async get(key) {
189
- console.log(`Retrieve env ${key}`);
191
+ logger.info(`Retrieve env ${key}`);
190
192
  const env = await this.envApi.get(key);
191
193
  if (!env) {
192
194
  throw new KnownException(`Environment ${key} is not found`);
193
195
  }
194
- console.log(`Env status: ${env.status}`);
196
+ logger.info(`Env status: ${env.status}`);
195
197
  return env;
196
198
  }
197
199
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import path from 'path';
3
+ import { logger } from '../logger.js';
3
4
  const CURRENT_VERSION = 1;
4
5
  export class LocalStateService {
5
6
  config;
@@ -16,16 +17,16 @@ export class LocalStateService {
16
17
  if (this.config) {
17
18
  return this.config;
18
19
  }
19
- console.log('Load local state config');
20
+ logger.info('Load local state config');
20
21
  const filePath = this.filePath();
21
22
  if (!fs.existsSync(filePath)) {
22
- console.warn('Local state file does not exist, must have been accidentally deleted, re-initializing');
23
+ logger.warn('Local state file does not exist, must have been accidentally deleted, re-initializing');
23
24
  return this.createEmptyConfig(filePath);
24
25
  }
25
26
  const data = fs.readFileSync(filePath, 'utf-8');
26
27
  let config = JSON.parse(data);
27
28
  if (config.version != CURRENT_VERSION) {
28
- console.log('Local state file version mismatch, re-initializing');
29
+ logger.info('Local state file version mismatch, re-initializing');
29
30
  config = this.createEmptyConfig(filePath);
30
31
  }
31
32
  this.config = config;
@@ -1,6 +1,7 @@
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
+ import { logger } from '../logger.js';
4
5
  import { BaseService } from './BaseService.js';
5
6
  export class LogService extends BaseService {
6
7
  cli;
@@ -33,7 +34,7 @@ export class LogService extends BaseService {
33
34
  throw new KnownException(`Failed to download logs: ${res.status} ${res.statusText}${msg ? ` - ${msg}` : ''}`);
34
35
  }
35
36
  const outPath = await this.cli.writeInTmpFile(res.body, outName);
36
- console.log(`Logs saved to: ${outPath}`);
37
+ logger.info(`Logs saved to: ${outPath}`);
37
38
  await this.cli.run('open', ['-R', outPath]);
38
39
  }
39
40
  }
@@ -1,10 +1,12 @@
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 { logger } from '../logger.js';
7
+ import { LocalStateService } from './LocalStateService.js';
8
+ export const MAX_ATTEMPTS = 2;
9
+ export const RETRYABLE_ERRORS = [
8
10
  'ConcurrentModificationException',
9
11
  'public policies are blocked by the BlockPublicPolicy block public access setting',
10
12
  'operation error Lambda: AddPermission, https response error StatusCode: 404',
@@ -14,11 +16,13 @@ function hash(data) {
14
16
  return createHash('sha256').update(data).digest('hex');
15
17
  }
16
18
  export class TerraformAdapter {
19
+ now;
17
20
  cli;
18
21
  localStateService;
19
22
  backends;
20
23
  backend = undefined;
21
- constructor(cli, localStateService, backends) {
24
+ constructor(now, cli, localStateService, backends) {
25
+ this.now = now;
22
26
  this.cli = cli;
23
27
  this.localStateService = localStateService;
24
28
  this.backends = backends.reduce((acc, backend) => {
@@ -100,7 +104,7 @@ export class TerraformAdapter {
100
104
  return res;
101
105
  }
102
106
  async lockProviders() {
103
- console.log('Update lock file');
107
+ logger.info('Update lock file');
104
108
  await this.cli.run('terraform', ['providers', 'lock', '-platform=linux_arm64']);
105
109
  }
106
110
  printTime() {
@@ -109,7 +113,7 @@ export class TerraformAdapter {
109
113
  const edt = now.toLocaleString('en-US', {
110
114
  timeZone: 'America/New_York'
111
115
  });
112
- console.log(`\nTime EDT: ${edt}, UTC: ${utc}\n`);
116
+ logger.info(`\nTime EDT: ${edt}, UTC: ${utc}\n`);
113
117
  }
114
118
  tfArgs(env, args, vars = []) {
115
119
  const varDefs = this.getVarDefs();
@@ -160,7 +164,7 @@ export class TerraformAdapter {
160
164
  const keyValue = arg.slice(5);
161
165
  const eqIndex = keyValue.indexOf('=');
162
166
  if (eqIndex === -1) {
163
- console.error('Terraform var argument is not in key=value format:', arg);
167
+ logger.error('Terraform var argument is not in key=value format: ' + arg);
164
168
  return;
165
169
  }
166
170
  const key = keyValue.slice(0, eqIndex);
@@ -176,7 +180,7 @@ export class TerraformAdapter {
176
180
  Object.entries(vars).forEach(([key, value]) => {
177
181
  const varDef = varDefs[key];
178
182
  if (!varDef) {
179
- console.warn(`Terraform variable ${key} passed, but not found in .tf files`);
183
+ logger.warn(`Terraform variable ${key} passed, but not found in .tf files`);
180
184
  return;
181
185
  }
182
186
  if (varDef.sensitive)
@@ -191,9 +195,9 @@ export class TerraformAdapter {
191
195
  if (env) {
192
196
  args = this.tfArgs(env, args);
193
197
  }
194
- await this._plan(args, onDemandVars);
198
+ await this._plan(args, onDemandVars, 1);
195
199
  }
196
- async _plan(args, onDemandVars, attemptNo = 1) {
200
+ async _plan(args, onDemandVars, attemptNo) {
197
201
  let inputTfVariable = false;
198
202
  let tfVarName = '';
199
203
  function out_scanner(line) {
@@ -212,32 +216,52 @@ export class TerraformAdapter {
212
216
  tfVarName = '';
213
217
  }
214
218
  }
215
- console.log('Running: terraform plan', ...args, '\n');
219
+ logger.info('Running: terraform plan ' + args.join(' ') + '\n');
216
220
  try {
217
- await this.cli.run('terraform', ['plan', ...args], out_scanner, in_scanner);
221
+ await this.cli.run('terraform', ['plan', ...args], NO_TIMEOUT, out_scanner, in_scanner);
218
222
  }
219
223
  catch (error) {
220
224
  if (!(error instanceof ProcessException)) {
221
225
  throw error;
222
226
  }
223
227
  if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
224
- console.warn(`Retrying terraform plan due to error: ${error.message}`);
228
+ logger.warn(`Retrying terraform plan due to error: ${error.message}`);
225
229
  return this._plan(args, onDemandVars, attemptNo + 1);
226
230
  }
227
231
  const lockId = this.lockId(error, attemptNo);
228
232
  if (lockId) {
229
233
  await this.promptUnlock(lockId);
230
- console.info('State unlocked, retrying terraform plan');
234
+ logger.info('State unlocked, retrying terraform plan');
231
235
  return this._plan(args, onDemandVars, attemptNo + 1);
232
236
  }
233
237
  throw new KnownException(`terraform plan failed with code ${error.code}:\n${error.message}`, { cause: error });
234
238
  }
235
239
  }
236
- async apply(args, attemptNo = 1) {
237
- console.log('Running: terraform apply', ...args, '\n');
240
+ async apply(args, ttl) {
241
+ logger.info('Running: terraform apply ' + args.join(' ') + '\n');
242
+ try {
243
+ await this._apply(args, ttl, 1);
244
+ }
245
+ catch (error) {
246
+ if (error instanceof TimeoutException) {
247
+ throw new KnownException('Reached environment ttl while running \'terraform apply\' command\n'
248
+ + 'Command was stopped to avoid race condition with deletion process\n'
249
+ + 'Please wait env deletion', { cause: error });
250
+ }
251
+ throw error;
252
+ }
253
+ }
254
+ async _apply(args, ttl, attemptNo) {
238
255
  this.printTime();
256
+ const nowUtcSeconds = Math.floor(this.now() / 1000);
257
+ const gracePeriod = 10;
258
+ const timeout = (ttl - nowUtcSeconds - gracePeriod) * 1000;
259
+ if (timeout <= 0) {
260
+ throw new KnownException('TTL expired before terraform apply could start');
261
+ }
262
+ logger.debug('timeout(ms): ' + timeout);
239
263
  try {
240
- await this.cli.run('terraform', ['apply', ...args]);
264
+ await this.cli.run('terraform', ['apply', ...args], timeout);
241
265
  this.printTime();
242
266
  }
243
267
  catch (error) {
@@ -245,14 +269,14 @@ export class TerraformAdapter {
245
269
  throw error;
246
270
  }
247
271
  if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
248
- console.warn(`Retrying terraform apply due to error: ${error.message}`);
249
- return this.apply(args, attemptNo + 1);
272
+ logger.warn(`Retrying terraform apply due to error: ${error.message}`);
273
+ return this._apply(args, ttl, attemptNo + 1);
250
274
  }
251
275
  const lockId = this.lockId(error, attemptNo);
252
276
  if (lockId) {
253
277
  await this.promptUnlock(lockId);
254
- console.info('State unlocked, retrying terraform apply');
255
- return this.apply(args, attemptNo + 1);
278
+ logger.info('State unlocked, retrying terraform apply');
279
+ return this._apply(args, ttl, attemptNo + 1);
256
280
  }
257
281
  throw new KnownException(`terraform apply failed with code ${error.code}:\n${error.message}`, { cause: error });
258
282
  }
@@ -268,9 +292,9 @@ export class TerraformAdapter {
268
292
  wrongDir = true;
269
293
  }
270
294
  };
271
- console.log('Running: terraform destroy -auto-approve', ...args, '\n');
295
+ logger.info('Running: terraform destroy -auto-approve' + args.join(' ') + '\n');
272
296
  try {
273
- await this.cli.run('terraform', ['destroy', '-auto-approve', ...args], scanner);
297
+ await this.cli.run('terraform', ['destroy', '-auto-approve', ...args], NO_TIMEOUT, scanner);
274
298
  }
275
299
  catch (error) {
276
300
  if (!(error instanceof ProcessException)) {
@@ -284,7 +308,7 @@ export class TerraformAdapter {
284
308
  else {
285
309
  await this.promptUnlock(lockId);
286
310
  }
287
- console.info('State unlocked, retrying terraform destroy');
311
+ logger.info('State unlocked, retrying terraform destroy');
288
312
  return this._destroy(args, force, attemptNo + 1);
289
313
  }
290
314
  throw new KnownException(`terraform destroy failed with code ${error.code}:\n${error.message}`, { cause: error });
@@ -297,7 +321,7 @@ export class TerraformAdapter {
297
321
  if (attemptNo < MAX_ATTEMPTS && error.message.includes('Error acquiring the state lock')) {
298
322
  const match = error.message.match(/ID:\s+([a-f0-9-]{36})/i);
299
323
  if (match) {
300
- console.error(error.message);
324
+ logger.error(error.message);
301
325
  return match[1];
302
326
  }
303
327
  }
@@ -312,7 +336,7 @@ export class TerraformAdapter {
312
336
  await this.forceUnlock(id);
313
337
  }
314
338
  async forceUnlock(id) {
315
- console.log('Force unlocking state');
339
+ logger.info('Force unlocking state');
316
340
  await this.cli.run('terraform', ['force-unlock', '-force', id]);
317
341
  }
318
342
  }
@@ -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.8.0",
4
4
  "description": "node.js CLI client for manage environments",
5
5
  "keywords": [
6
6
  "terraform wrapper",
@@ -37,11 +37,12 @@
37
37
  "test": "vitest run --coverage",
38
38
  "build": "tsc -p tsconfig.build.json",
39
39
  "run": "tsc -p tsconfig.build.json --sourceMap true && node dist/index.js",
40
+ "run-help": " npm run run -- deploy --help",
40
41
  "run-version": " npm run run -- --version",
41
42
  "run-configure": "npm run run -- configure",
42
43
  "~": "",
43
44
  "run-core-init": " cd ../tt-core && terraform init -upgrade -backend-config=key=laxa1986 -reconfigure",
44
- "run-core-status": " npm run run -- status --cwd ../tt-core",
45
+ "run-core-status": " npm run run -- status --cwd ../tt-core -v",
45
46
  "run-core-plan": " npm run run -- plan --cwd ../tt-core -out=.terraform/plan",
46
47
  "run-core-deployp": "npm run run -- deploy --cwd ../tt-core .terraform/plan",
47
48
  "run-core-deploy": " npm run run -- deploy --cwd ../tt-core -var=\"env_size=min\"",