@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.
- package/dist/client/Cli.js +59 -21
- package/dist/client/EnvApiClient.js +3 -2
- package/dist/client/HttpClient.js +6 -1
- package/dist/client/index.js +0 -1
- package/dist/commands/_keys.js +2 -1
- package/dist/commands/_utils.js +4 -7
- package/dist/commands/configure.js +2 -0
- package/dist/commands/createEphemeral.js +1 -0
- package/dist/commands/delete.js +1 -0
- package/dist/commands/deploy.js +1 -0
- package/dist/commands/destroy.js +1 -0
- package/dist/commands/logs.js +1 -0
- package/dist/commands/plan.js +1 -0
- package/dist/commands/status.js +1 -0
- package/dist/container.js +3 -3
- package/dist/exceptions.js +13 -11
- package/dist/logger.js +36 -0
- package/dist/service/BaseService.js +2 -2
- package/dist/service/EnvService.js +26 -24
- package/dist/service/LocalStateService.js +4 -3
- package/dist/service/LogService.js +3 -2
- package/dist/{client → service}/TerraformAdapter.js +51 -27
- package/dist/service/index.js +1 -0
- package/package.json +3 -2
package/dist/client/Cli.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
50
|
+
throw new HttpException(message);
|
|
46
51
|
}
|
|
47
52
|
}
|
package/dist/client/index.js
CHANGED
package/dist/commands/_keys.js
CHANGED
|
@@ -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
|
};
|
package/dist/commands/_utils.js
CHANGED
|
@@ -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
|
-
|
|
7
|
+
await callable(...args);
|
|
8
8
|
}
|
|
9
9
|
catch (error) {
|
|
10
10
|
if (error instanceof KnownException) {
|
|
11
|
-
|
|
11
|
+
logger.error(error.message);
|
|
12
12
|
}
|
|
13
13
|
else if (error instanceof AbortedException || error instanceof ExitPromptError) {
|
|
14
14
|
}
|
|
15
15
|
else {
|
|
16
|
-
|
|
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) {
|
package/dist/commands/delete.js
CHANGED
package/dist/commands/deploy.js
CHANGED
|
@@ -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));
|
package/dist/commands/destroy.js
CHANGED
|
@@ -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));
|
package/dist/commands/logs.js
CHANGED
|
@@ -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) {
|
package/dist/commands/plan.js
CHANGED
|
@@ -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));
|
package/dist/commands/status.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
|
+
}
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
+
logger.info(`Retrieve env ${key}`);
|
|
18
19
|
const env = await this.envApi.get(key);
|
|
19
20
|
if (env === null) {
|
|
20
|
-
|
|
21
|
+
logger.info(`Env ${key} does not exist`);
|
|
21
22
|
return;
|
|
22
23
|
}
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
+
logger.info(`Check if env ${key} already exists`);
|
|
45
46
|
const env = await this.envApi.get(key);
|
|
46
47
|
if (env) {
|
|
47
|
-
|
|
48
|
+
logger.info(`Found ${key} env`);
|
|
48
49
|
}
|
|
49
50
|
else {
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
+
logger.info('Aborting deployment');
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
if (status === EnvStatus.Init || status === EnvStatus.Active) {
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
await this.terraformAdapter.apply(tfArgs);
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
+
logger.info('Please wait for ~30 sec before you can create env with same name');
|
|
187
189
|
}
|
|
188
190
|
async get(key) {
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
logger.info('Load local state config');
|
|
20
21
|
const filePath = this.filePath();
|
|
21
22
|
if (!fs.existsSync(filePath)) {
|
|
22
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
237
|
-
|
|
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
|
-
|
|
249
|
-
return this.
|
|
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
|
-
|
|
255
|
-
return this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
+
logger.info('Force unlocking state');
|
|
316
340
|
await this.cli.run('terraform', ['force-unlock', '-force', id]);
|
|
317
341
|
}
|
|
318
342
|
}
|
package/dist/service/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilecustoms/envctl",
|
|
3
|
-
"version": "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\"",
|