@agilecustoms/envctl 0.5.1 → 0.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/README.md +6 -0
- package/dist/client/EnvApiClient.js +54 -0
- package/dist/client/HttpClient.js +74 -0
- package/dist/client/ProcessRunner.js +51 -0
- package/dist/client/TerraformAdapter.js +88 -0
- package/dist/client/index.js +3 -0
- package/dist/commands/delete.js +5 -4
- package/dist/commands/deploy.js +22 -0
- package/dist/commands/index.js +1 -3
- package/dist/commands/utils.js +33 -0
- package/dist/container.js +7 -5
- package/dist/exceptions.js +31 -29
- package/dist/index.js +2 -4
- package/dist/service/EnvCtl.js +74 -37
- package/dist/service/index.js +0 -2
- package/package.json +4 -6
- package/scripts/terraform-init.sh +12 -19
- package/dist/commands/create-ephemeral.js +0 -14
- package/dist/commands/register.js +0 -15
- package/dist/commands/terraform-init.js +0 -31
- package/dist/model/PersonalEnvironment.js +0 -1
- package/dist/service/DevEnvClient.js +0 -38
- package/dist/service/HttpClient.js +0 -48
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
- npm [@agilecustoms/envctl](https://www.npmjs.com/package/@agilecustoms/envctl)
|
|
3
3
|
- npm [agilecustoms/packages](https://www.npmjs.com/settings/agilecustoms/packages) (admin view)
|
|
4
4
|
|
|
5
|
+
## usage
|
|
6
|
+
```shell
|
|
7
|
+
envctl deploy --key tt-alexc --owner laxa1986 --size min --type dev \
|
|
8
|
+
-var-file=versions.tfvars -var="env=tt-alex" -var="log_level=debug"
|
|
9
|
+
```
|
|
10
|
+
|
|
5
11
|
## setup/update
|
|
6
12
|
```shell
|
|
7
13
|
npm install -g @agilecustoms/envctl # same command for update
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { KnownException, NotFoundException } from '../exceptions.js';
|
|
2
|
+
import { HttpClient } from './HttpClient.js';
|
|
3
|
+
export class EnvApiClient {
|
|
4
|
+
httpClient;
|
|
5
|
+
constructor(httpClient) {
|
|
6
|
+
this.httpClient = httpClient;
|
|
7
|
+
}
|
|
8
|
+
async get(key) {
|
|
9
|
+
try {
|
|
10
|
+
return await this.httpClient.fetch(`/ci/env/${key}`);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof NotFoundException) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async create(env) {
|
|
20
|
+
const res = await this.httpClient.fetch('/ci/env', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
body: JSON.stringify(env),
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json'
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return res.token;
|
|
28
|
+
}
|
|
29
|
+
async activate(key) {
|
|
30
|
+
await this.httpClient.fetch(`/ci/env/${key}/activate`, {
|
|
31
|
+
method: 'POST'
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async lockForUpdate(key) {
|
|
35
|
+
await this.httpClient.fetch(`/ci/env/${key}/lock-for-update`, {
|
|
36
|
+
method: 'POST'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async delete(key) {
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = await this.httpClient.fetch(`/ci/env/${key}`, {
|
|
43
|
+
method: 'DELETE'
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof NotFoundException) {
|
|
48
|
+
throw new KnownException(`Environment ${key} is not found`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
return result.statusText;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fromEnv, fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
|
2
|
+
import { CredentialsProviderError } from '@smithy/property-provider';
|
|
3
|
+
import aws4 from 'aws4';
|
|
4
|
+
import { BusinessException, HttpException, KnownException, NotFoundException } from '../exceptions.js';
|
|
5
|
+
const HOST = 'env-api.maintenance.agilecustoms.com';
|
|
6
|
+
export class HttpClient {
|
|
7
|
+
creds = undefined;
|
|
8
|
+
async fetch(path, options = {}) {
|
|
9
|
+
if (!this.creds) {
|
|
10
|
+
this.creds = await this.getCredentials();
|
|
11
|
+
}
|
|
12
|
+
const requestOptions = {
|
|
13
|
+
method: options.method,
|
|
14
|
+
body: options.body,
|
|
15
|
+
headers: (options.headers ?? {}),
|
|
16
|
+
host: HOST,
|
|
17
|
+
service: 'execute-api',
|
|
18
|
+
path,
|
|
19
|
+
};
|
|
20
|
+
const signedOptions = aws4.sign(requestOptions, this.creds);
|
|
21
|
+
const signedHeaders = signedOptions.headers;
|
|
22
|
+
const url = `https://${HOST}${path}`;
|
|
23
|
+
options.headers = signedHeaders;
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await fetch(url, options);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new Error('Error (network?) making the request:', { cause: error });
|
|
30
|
+
}
|
|
31
|
+
const contentType = response.headers?.get('Content-Type') || '';
|
|
32
|
+
if (response.ok) {
|
|
33
|
+
if (contentType.includes('application/json')) {
|
|
34
|
+
return await response.json();
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
return await response.text();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 404) {
|
|
41
|
+
const message = await response.text();
|
|
42
|
+
throw new NotFoundException(message);
|
|
43
|
+
}
|
|
44
|
+
if (response.status === 422) {
|
|
45
|
+
let message = await response.text();
|
|
46
|
+
let params;
|
|
47
|
+
if (contentType.includes('application/json')) {
|
|
48
|
+
const json = await response.json();
|
|
49
|
+
message = json.message;
|
|
50
|
+
params = json.params;
|
|
51
|
+
}
|
|
52
|
+
throw new BusinessException(message, params);
|
|
53
|
+
}
|
|
54
|
+
throw new HttpException(response.status, await response.text());
|
|
55
|
+
}
|
|
56
|
+
async getCredentials() {
|
|
57
|
+
let identityProvider;
|
|
58
|
+
if (process.env.CI) {
|
|
59
|
+
identityProvider = fromEnv();
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
identityProvider = fromNodeProviderChain();
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return await identityProvider();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof CredentialsProviderError) {
|
|
69
|
+
throw new KnownException(error.message, { cause: error });
|
|
70
|
+
}
|
|
71
|
+
throw new Error('Error fetching credentials:', { cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { ProcessException } from '../exceptions.js';
|
|
5
|
+
export class ProcessRunner {
|
|
6
|
+
async runScript(script, args, cwd, scanner) {
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const scriptPath = path.join(__dirname, `../../scripts/${script}`);
|
|
9
|
+
return this.run(scriptPath, args, cwd, scanner);
|
|
10
|
+
}
|
|
11
|
+
async run(command, args, cwd, scanner) {
|
|
12
|
+
const spawnOptions = {};
|
|
13
|
+
if (cwd) {
|
|
14
|
+
spawnOptions.cwd = cwd;
|
|
15
|
+
}
|
|
16
|
+
const child = spawn(command, args, spawnOptions);
|
|
17
|
+
child.stdout.setEncoding('utf8');
|
|
18
|
+
child.stderr.setEncoding('utf8');
|
|
19
|
+
let buffer = '';
|
|
20
|
+
child.stdout.on('data', (data) => {
|
|
21
|
+
buffer += data;
|
|
22
|
+
const lines = buffer.split('\n');
|
|
23
|
+
buffer = lines.pop() || '';
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
if (scanner)
|
|
26
|
+
scanner(line);
|
|
27
|
+
console.log(`> ${line}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
let errorBuffer = '';
|
|
31
|
+
child.stderr.on('data', (data) => {
|
|
32
|
+
errorBuffer += data;
|
|
33
|
+
});
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
child.on('close', (code) => {
|
|
36
|
+
if (scanner)
|
|
37
|
+
scanner(buffer);
|
|
38
|
+
console.log(`> ${buffer}`);
|
|
39
|
+
if (code === 0) {
|
|
40
|
+
if (errorBuffer) {
|
|
41
|
+
console.warn('Process completed successfully, but there were errors:\n' + errorBuffer);
|
|
42
|
+
}
|
|
43
|
+
resolve();
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
reject(new ProcessException(code, errorBuffer));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { promptYesNo } from '../commands/utils.js';
|
|
2
|
+
import { ExitException, KnownException, ProcessException } from '../exceptions.js';
|
|
3
|
+
const MAX_ATTEMPTS = 2;
|
|
4
|
+
const RETRYABLE_ERRORS = [
|
|
5
|
+
'ConcurrentModificationException',
|
|
6
|
+
'public policies are blocked by the BlockPublicPolicy block public access setting'
|
|
7
|
+
];
|
|
8
|
+
export class TerraformAdapter {
|
|
9
|
+
processRunner;
|
|
10
|
+
constructor(processRunner) {
|
|
11
|
+
this.processRunner = processRunner;
|
|
12
|
+
}
|
|
13
|
+
printTime() {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
const utc = now.toISOString();
|
|
16
|
+
const est = now.toLocaleString('en-US', {
|
|
17
|
+
timeZone: 'America/New_York'
|
|
18
|
+
});
|
|
19
|
+
console.log(`\nTime EST: ${est}, UTC: ${utc}\n`);
|
|
20
|
+
}
|
|
21
|
+
async init(key, cwd) {
|
|
22
|
+
let emptyDir = false;
|
|
23
|
+
const scanner = (line) => {
|
|
24
|
+
if (line.includes('Terraform initialized in an empty directory!')) {
|
|
25
|
+
emptyDir = true;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
this.printTime();
|
|
29
|
+
try {
|
|
30
|
+
await this.processRunner.runScript('terraform-init.sh', [key], cwd, scanner);
|
|
31
|
+
this.printTime();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof ProcessException) {
|
|
35
|
+
throw new KnownException(`terraform init failed with code ${error.code}:\n${error.message}`, { cause: error });
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
if (emptyDir) {
|
|
40
|
+
throw new KnownException('Can not find terraform files. Command needs to be run in a directory with terraform files');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async deploy(envAttrs, tfArgs, cwd, attemptNo = 1) {
|
|
44
|
+
const args = [
|
|
45
|
+
`-var=project=${envAttrs.project}`,
|
|
46
|
+
`-var=env=${envAttrs.env}`,
|
|
47
|
+
`-var=owner=${envAttrs.owner}`,
|
|
48
|
+
`-var=env_size=${envAttrs.size}`,
|
|
49
|
+
`-var=env_type=${envAttrs.type}`,
|
|
50
|
+
...tfArgs
|
|
51
|
+
];
|
|
52
|
+
console.log('Running:', 'terraform apply -auto-approve', ...args);
|
|
53
|
+
this.printTime();
|
|
54
|
+
try {
|
|
55
|
+
await this.processRunner.run('terraform', ['apply', '-auto-approve', ...args], cwd);
|
|
56
|
+
this.printTime();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof ProcessException) {
|
|
60
|
+
if (attemptNo < MAX_ATTEMPTS && RETRYABLE_ERRORS.some(err => error.message.includes(err))) {
|
|
61
|
+
console.warn(`Retrying terraform apply due to error: ${error.message}`);
|
|
62
|
+
return this.deploy(envAttrs, tfArgs, cwd, attemptNo + 1);
|
|
63
|
+
}
|
|
64
|
+
if (attemptNo < MAX_ATTEMPTS && error.message.includes('Error acquiring the state lock')) {
|
|
65
|
+
const match = error.message.match(/ID:\s+([a-f0-9-]{36})/i);
|
|
66
|
+
if (match) {
|
|
67
|
+
console.error(error.message);
|
|
68
|
+
const answerYes = await promptYesNo('Terraform state is locked. Most often due to previously interrupted process.\n'
|
|
69
|
+
+ 'Rarely (main lock intention) is when another process actively work with the state to avoid inconsistent modification.\n'
|
|
70
|
+
+ 'Do you want to force unlock? (y/n): ');
|
|
71
|
+
if (answerYes) {
|
|
72
|
+
console.log('Force unlocking state');
|
|
73
|
+
const id = match[1];
|
|
74
|
+
await this.processRunner.run('terraform', ['force-unlock', '-force', id], cwd);
|
|
75
|
+
console.info('State unlocked, retrying terraform apply');
|
|
76
|
+
return this.deploy(envAttrs, tfArgs, cwd, attemptNo + 1);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
throw new ExitException();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new KnownException(`terraform apply failed with code ${error.code}:\n${error.message}`, { cause: error });
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/commands/delete.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { envCtl } from '../container.js';
|
|
3
|
-
import { wrap } from '
|
|
3
|
+
import { wrap } from './utils.js';
|
|
4
4
|
export function deleteIt(program) {
|
|
5
5
|
program
|
|
6
6
|
.command('delete')
|
|
7
7
|
.description('Delete a development environment')
|
|
8
|
-
.
|
|
8
|
+
.requiredOption('--project <project>', 'Project name: can be project code (like tt when deploy whole project), or {code}-{microservice} if you want to deploy a single microservice')
|
|
9
|
+
.requiredOption('--env <env>', 'Environment name (can be git hash). {project}-{env} give env key used to store env state (s3 key in case of AWS)')
|
|
9
10
|
.action(wrap(handler));
|
|
10
11
|
}
|
|
11
|
-
async function handler(
|
|
12
|
-
await envCtl.delete(
|
|
12
|
+
async function handler(options) {
|
|
13
|
+
await envCtl.delete(options.project, options.env);
|
|
13
14
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { envCtl } from '../container.js';
|
|
3
|
+
import { wrap } from './utils.js';
|
|
4
|
+
export function deploy(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('deploy')
|
|
7
|
+
.description('Create new or update existing dev environment')
|
|
8
|
+
.requiredOption('--project <project>', 'Project name: can be project code (like tt when deploy whole project), or {code}-{microservice} if you want to deploy a single microservice')
|
|
9
|
+
.requiredOption('--env <env>', 'Environment name (can be git hash). {project}-{env} give env key used to store env state (s3 key in case of AWS)')
|
|
10
|
+
.requiredOption('--owner <owner>', 'Environment owner (GH username)')
|
|
11
|
+
.requiredOption('--size <size>', 'Environment size: min, small, full')
|
|
12
|
+
.requiredOption('--type <type>', 'Environment type: dev, prod')
|
|
13
|
+
.option('--cwd <cwd>', 'Working directory (default: current directory)')
|
|
14
|
+
.allowUnknownOption(true)
|
|
15
|
+
.argument('[args...]')
|
|
16
|
+
.action(wrap(handler));
|
|
17
|
+
}
|
|
18
|
+
async function handler(tfArgs, options) {
|
|
19
|
+
const { project, env, owner, size, type } = options;
|
|
20
|
+
const envAttributes = { project, env, owner, size, type };
|
|
21
|
+
await envCtl.deploy(envAttributes, tfArgs, options.cwd);
|
|
22
|
+
}
|
package/dist/commands/index.js
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from 'process';
|
|
2
|
+
import { createInterface } from 'readline/promises';
|
|
3
|
+
import { BusinessException, ExitException, KnownException } from '../exceptions.js';
|
|
4
|
+
export function wrap(callable) {
|
|
5
|
+
return async (...args) => {
|
|
6
|
+
let result;
|
|
7
|
+
try {
|
|
8
|
+
result = await callable(...args);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error instanceof KnownException || error instanceof BusinessException) {
|
|
12
|
+
console.error(error.message);
|
|
13
|
+
}
|
|
14
|
+
else if (!(error instanceof ExitException)) {
|
|
15
|
+
console.error('Unknown error:', error);
|
|
16
|
+
}
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
if (result !== undefined) {
|
|
20
|
+
console.log(result);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function prompt(question) {
|
|
25
|
+
const rl = createInterface({ input, output });
|
|
26
|
+
const answer = await rl.question(question);
|
|
27
|
+
rl.close();
|
|
28
|
+
return answer;
|
|
29
|
+
}
|
|
30
|
+
export async function promptYesNo(question) {
|
|
31
|
+
const answer = await prompt(question);
|
|
32
|
+
return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
|
|
33
|
+
}
|
package/dist/container.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { EnvApiClient, HttpClient, TerraformAdapter } from './client/index.js';
|
|
2
|
+
import { ProcessRunner } from './client/ProcessRunner.js';
|
|
3
|
+
import { EnvCtl } from './service/index.js';
|
|
4
4
|
const httpClient = new HttpClient();
|
|
5
|
-
const
|
|
6
|
-
const
|
|
5
|
+
const envApiClient = new EnvApiClient(httpClient);
|
|
6
|
+
const processRunner = new ProcessRunner();
|
|
7
|
+
const terraformAdapter = new TerraformAdapter(processRunner);
|
|
8
|
+
const envCtl = new EnvCtl(envApiClient, terraformAdapter);
|
|
7
9
|
export { envCtl };
|
package/dist/exceptions.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export class KnownException extends Error {
|
|
2
|
-
constructor(message) {
|
|
3
|
-
super(message);
|
|
2
|
+
constructor(message, options) {
|
|
3
|
+
super(message, options);
|
|
4
4
|
this.name = 'KnownException';
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
+
export class ExitException extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
this.name = 'ExitException';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
7
13
|
export class HttpException extends Error {
|
|
8
14
|
statusCode;
|
|
9
15
|
responseBody;
|
|
@@ -14,31 +20,27 @@ export class HttpException extends Error {
|
|
|
14
20
|
this.name = 'HttpException';
|
|
15
21
|
}
|
|
16
22
|
}
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (result !== undefined) {
|
|
41
|
-
console.log(result);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
23
|
+
export class BusinessException extends HttpException {
|
|
24
|
+
message;
|
|
25
|
+
params;
|
|
26
|
+
constructor(message, params) {
|
|
27
|
+
super(422, message);
|
|
28
|
+
this.message = message;
|
|
29
|
+
this.params = params;
|
|
30
|
+
this.name = 'BusinessException';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class NotFoundException extends HttpException {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(404, message);
|
|
36
|
+
this.name = 'NotFoundException';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class ProcessException extends Error {
|
|
40
|
+
code;
|
|
41
|
+
constructor(code, message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.name = 'ProcessException';
|
|
45
|
+
}
|
|
44
46
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import updateNotifier from 'update-notifier';
|
|
5
|
-
import { deleteIt,
|
|
5
|
+
import { deleteIt, deploy } from './commands/index.js';
|
|
6
6
|
const require = createRequire(import.meta.url);
|
|
7
7
|
const pkg = require('../package.json');
|
|
8
8
|
updateNotifier({ pkg, updateCheckInterval: 0 }).notify();
|
|
@@ -11,8 +11,6 @@ program
|
|
|
11
11
|
.name('envctl')
|
|
12
12
|
.description('CLI to manage environments')
|
|
13
13
|
.version(pkg.version);
|
|
14
|
-
|
|
15
|
-
ephemeral(program);
|
|
14
|
+
deploy(program);
|
|
16
15
|
deleteIt(program);
|
|
17
|
-
terraformInit(program);
|
|
18
16
|
program.parse(process.argv);
|
package/dist/service/EnvCtl.js
CHANGED
|
@@ -1,46 +1,83 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { promptYesNo } from '../commands/utils.js';
|
|
2
2
|
import { KnownException } from '../exceptions.js';
|
|
3
|
-
import { DevEnvClient } from './DevEnvClient.js';
|
|
4
3
|
export class EnvCtl {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
constructor(
|
|
8
|
-
this.
|
|
9
|
-
this.
|
|
4
|
+
envApi;
|
|
5
|
+
terraformAdapter;
|
|
6
|
+
constructor(envApi, terraformAdapter) {
|
|
7
|
+
this.envApi = envApi;
|
|
8
|
+
this.terraformAdapter = terraformAdapter;
|
|
10
9
|
}
|
|
11
|
-
async
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
async deploy(envDto, tfArgs, cwd) {
|
|
11
|
+
const key = `${envDto.project}-${envDto.env}`;
|
|
12
|
+
console.log(`Check if env ${key} already exists`);
|
|
13
|
+
const env = await this.envApi.get(key);
|
|
14
|
+
if (env === null) {
|
|
15
|
+
console.log(`Env ${key} does not exist, creating it`);
|
|
16
|
+
console.log('First run terraform init to download providers, this doesn\'t create any resources in AWS even S3 object');
|
|
17
|
+
await this.terraformAdapter.init(key, cwd);
|
|
18
|
+
console.log('Creating env tracking record in DynamoDB');
|
|
19
|
+
const { owner, size, type } = envDto;
|
|
20
|
+
const createEnv = { key, ephemeral: false, owner, size, type };
|
|
21
|
+
await this.envApi.create(createEnv);
|
|
22
|
+
return await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
23
|
+
}
|
|
24
|
+
if (env.ephemeral) {
|
|
25
|
+
throw new KnownException(`Attempted to convert ephemeral env ${key} to non-ephemeral`);
|
|
26
|
+
}
|
|
27
|
+
if (env.owner !== envDto.owner) {
|
|
28
|
+
throw new KnownException(`Can not change env owner ${env.owner} -> ${envDto.owner}`);
|
|
29
|
+
}
|
|
30
|
+
if (env.size !== envDto.size) {
|
|
31
|
+
throw new KnownException(`Can not change env size ${env.size} -> ${envDto.size}`);
|
|
32
|
+
}
|
|
33
|
+
if (env.type !== envDto.type) {
|
|
34
|
+
throw new KnownException(`Can not change env type ${env.type} -> ${envDto.type}`);
|
|
35
|
+
}
|
|
36
|
+
if (env.status === 'CREATING') {
|
|
37
|
+
const answerYes = await promptYesNo('Env status is CREATING, likely to error in previous run\nDo you want to proceed with deployment? (y/n) ');
|
|
38
|
+
if (answerYes) {
|
|
39
|
+
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
26
40
|
}
|
|
27
|
-
throw new Error('Error fetching account', { cause: error });
|
|
28
41
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
else if (env.status === 'UPDATING') {
|
|
43
|
+
const answerYes = await promptYesNo('Env status is UPDATING, likely to error in previous run\nDo you want to proceed with deployment? (y/n) ');
|
|
44
|
+
if (answerYes) {
|
|
45
|
+
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (env.status === 'ACTIVE') {
|
|
49
|
+
console.log('Env status is ACTIVE\nWill lock for update and run terraform apply (to update resources)');
|
|
50
|
+
await this.envApi.lockForUpdate(key);
|
|
51
|
+
await this.runDeploy(key, envDto, tfArgs, cwd);
|
|
52
|
+
}
|
|
35
53
|
}
|
|
36
|
-
async
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
async runDeploy(key, envAttrs, tfArgs, cwd) {
|
|
55
|
+
console.log('Deploying resources');
|
|
56
|
+
await this.terraformAdapter.deploy(envAttrs, tfArgs, cwd);
|
|
57
|
+
console.log('Activating env (to finish creation)');
|
|
58
|
+
await this.envApi.activate(key);
|
|
59
|
+
console.log('Lock env to run db evolution');
|
|
60
|
+
await this.envApi.lockForUpdate(key);
|
|
61
|
+
console.log('Unlock env after db evolution');
|
|
62
|
+
await this.envApi.activate(key);
|
|
40
63
|
}
|
|
41
|
-
async delete(
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
64
|
+
async delete(project, envName) {
|
|
65
|
+
const key = `${project}-${envName}`;
|
|
66
|
+
console.log(`Retrieve env`);
|
|
67
|
+
const env = await this.envApi.get(key);
|
|
68
|
+
if (env === null) {
|
|
69
|
+
throw new KnownException(`Environment ${key} does not exist`);
|
|
70
|
+
}
|
|
71
|
+
if (env.status === 'CREATING') {
|
|
72
|
+
const answerYes = await promptYesNo('Environment is still being created.\nDo you want to delete it? (y/n) ');
|
|
73
|
+
if (!answerYes) {
|
|
74
|
+
throw new KnownException('Aborting env deletion');
|
|
75
|
+
}
|
|
76
|
+
console.log('Activate (unlock) env (so it can be deleted)');
|
|
77
|
+
await this.envApi.activate(key);
|
|
78
|
+
}
|
|
79
|
+
console.log('Deleting env');
|
|
80
|
+
const statusMessage = await this.envApi.delete(key);
|
|
81
|
+
console.log(statusMessage);
|
|
45
82
|
}
|
|
46
83
|
}
|
package/dist/service/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilecustoms/envctl",
|
|
3
3
|
"description": "node.js CLI client for manage environments",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"author": "Alex Chekulaev",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -25,11 +25,9 @@
|
|
|
25
25
|
"test": "vitest run --coverage",
|
|
26
26
|
"build": "tsc",
|
|
27
27
|
"run": "node dist/index.js",
|
|
28
|
-
"run-version": "tsc --sourceMap true
|
|
29
|
-
"run-
|
|
30
|
-
"run-
|
|
31
|
-
"run-delete": "tsc --sourceMap true; npm run run -- delete tt-alexc",
|
|
32
|
-
"run-terraform-init": "tsc --sourceMap true; npm run run -- terraform-init test"
|
|
28
|
+
"run-version": "tsc --sourceMap true && npm run run -- --version",
|
|
29
|
+
"run-deploy": "tsc --sourceMap true && AWS_PROFILE=ac-tt-dev-deployer npm run run -- deploy --project tt-core --env alexc --owner laxa1986 --size min --type dev --cwd ../tt-core",
|
|
30
|
+
"run-delete": "tsc --sourceMap true && AWS_PROFILE=ac-tt-dev-deployer npm run run -- delete --project tt-core --env alexc"
|
|
33
31
|
},
|
|
34
32
|
"dependencies": {
|
|
35
33
|
"@aws-sdk/client-sts": "^3.716.0",
|
|
@@ -3,23 +3,16 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
KEY="${1:?}"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# TF state is stored in S3 in format: {company}-{acc-alias}-tf-state/{key}
|
|
7
|
+
# (for non ephemeral environments the {key} typically has form of {project-code}-{env-name} like tt-dev)
|
|
8
|
+
# Retrieve AWS account information
|
|
9
|
+
acc_id=$(aws sts get-caller-identity --query "Account" --output text)
|
|
10
|
+
acc_alias=$(aws organizations list-tags-for-resource --resource-id "$acc_id" --query "Tags[?Key=='Alias'].Value" --output text)
|
|
11
|
+
state_prefix="agilecustoms-$acc_alias" # like "agilecustoms-tt-dev"
|
|
7
12
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
#acc_alias=$(aws organizations list-tags-for-resource --resource-id "$acc_id" --query "Tags[?Key=='Alias'].Value" --output text)
|
|
15
|
-
#state_prefix="agilecustoms-$acc_alias" # like "agilecustoms-tt-dev"
|
|
16
|
-
#
|
|
17
|
-
## -upgrade - get latest version of providers (mainly hashicorp/aws)
|
|
18
|
-
## -reconfigure - discard local state, use (or create) remote
|
|
19
|
-
## added to allow deploy multiple envs from local machine (on CI no local state survive between runs)
|
|
20
|
-
#terraform init -upgrade -reconfigure \
|
|
21
|
-
# -backend-config="bucket=$state_prefix-tf-state" \
|
|
22
|
-
# -backend-config="key=$KEY"
|
|
23
|
-
#
|
|
24
|
-
#echo
|
|
25
|
-
#echo "time: $(TZ=America/New_York date +"%T") ETD ($(date -u +"%T") UTC)"
|
|
13
|
+
# -upgrade - get latest version of providers (mainly hashicorp/aws)
|
|
14
|
+
# -reconfigure - discard local state, use (or create) remote
|
|
15
|
+
# added to allow deploy multiple envs from local machine (on CI no local state survive between runs)
|
|
16
|
+
terraform init -upgrade -reconfigure \
|
|
17
|
+
-backend-config="bucket=$state_prefix-tf-state" \
|
|
18
|
+
-backend-config="key=$KEY"
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import { envCtl } from '../container.js';
|
|
3
|
-
import { wrap } from '../exceptions.js';
|
|
4
|
-
export function ephemeral(program) {
|
|
5
|
-
program
|
|
6
|
-
.command('ephemeral')
|
|
7
|
-
.description('Create new ephemeral environment')
|
|
8
|
-
.requiredOption('-k, --key <key>', 'Key used to store env state (s3 key in case of AWS). Can be git hash, feature-a or {project-code}-{env-name}')
|
|
9
|
-
.action(wrap(handler));
|
|
10
|
-
}
|
|
11
|
-
async function handler(options) {
|
|
12
|
-
const { key } = options;
|
|
13
|
-
return await envCtl.createEphemeralEnv(key);
|
|
14
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import { envCtl } from '../container.js';
|
|
3
|
-
import { wrap } from '../exceptions.js';
|
|
4
|
-
export function register(program) {
|
|
5
|
-
program
|
|
6
|
-
.command('register')
|
|
7
|
-
.description('Create new or update existing dev environment')
|
|
8
|
-
.requiredOption('-k, --key <key>', 'Key used to store env state (s3 key in case of AWS). Can be git hash, feature-a or {project-code}-{env-name}')
|
|
9
|
-
.requiredOption('-o, --owner <owner>', 'Environment owner (GH username)')
|
|
10
|
-
.action(wrap(handler));
|
|
11
|
-
}
|
|
12
|
-
async function handler(options) {
|
|
13
|
-
const { key, owner } = options;
|
|
14
|
-
return await envCtl.registerEnv(key, owner);
|
|
15
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'child_process';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
import { Command } from 'commander';
|
|
5
|
-
import { wrap } from '../exceptions.js';
|
|
6
|
-
export function terraformInit(program) {
|
|
7
|
-
program
|
|
8
|
-
.command('terraform-init')
|
|
9
|
-
.description('Wrapper for terraform init, to initialize a development environment')
|
|
10
|
-
.argument('<string>', 'key used to create/register the environment')
|
|
11
|
-
.action(wrap(handler));
|
|
12
|
-
}
|
|
13
|
-
async function handler(key) {
|
|
14
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const scriptPath = path.join(__dirname, '../../scripts/terraform-init.sh');
|
|
16
|
-
const child = execFile(scriptPath, [key], (error, stdout, stderr) => {
|
|
17
|
-
if (error) {
|
|
18
|
-
console.error(`Script failed: ${error.message}`);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
if (stdout)
|
|
22
|
-
console.log(stdout);
|
|
23
|
-
if (stderr)
|
|
24
|
-
console.error(stderr);
|
|
25
|
-
});
|
|
26
|
-
child.on('exit', (code) => {
|
|
27
|
-
if (code !== 0) {
|
|
28
|
-
console.log(`Shell script exited with code ${code}`);
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { HttpException, KnownException } from '../exceptions.js';
|
|
2
|
-
import { HttpClient } from './HttpClient.js';
|
|
3
|
-
export class DevEnvClient {
|
|
4
|
-
httpClient;
|
|
5
|
-
constructor(httpClient) {
|
|
6
|
-
this.httpClient = httpClient;
|
|
7
|
-
}
|
|
8
|
-
async registerEnv(env) {
|
|
9
|
-
return await this.send('PUT', '/ci/env', env);
|
|
10
|
-
}
|
|
11
|
-
async createEphemeralEnv(env) {
|
|
12
|
-
return await this.send('POST', '/ci/env', env);
|
|
13
|
-
}
|
|
14
|
-
async delete(envId) {
|
|
15
|
-
try {
|
|
16
|
-
await this.httpClient.fetch(`/ci/env/${envId}`, {
|
|
17
|
-
method: 'DELETE'
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
catch (error) {
|
|
21
|
-
if (error instanceof HttpException) {
|
|
22
|
-
if (error.statusCode === 404) {
|
|
23
|
-
throw new KnownException(`Environment ${envId} is not found`);
|
|
24
|
-
}
|
|
25
|
-
throw error;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async send(method, path, env) {
|
|
30
|
-
return this.httpClient.fetch(path, {
|
|
31
|
-
method,
|
|
32
|
-
body: JSON.stringify(env),
|
|
33
|
-
headers: {
|
|
34
|
-
'Content-Type': 'application/json'
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { fromEnv, fromSSO } from '@aws-sdk/credential-providers';
|
|
2
|
-
import aws4 from 'aws4';
|
|
3
|
-
import { HttpException } from '../exceptions.js';
|
|
4
|
-
const HOST = 'env-api.maintenance.agilecustoms.com';
|
|
5
|
-
export class HttpClient {
|
|
6
|
-
async fetch(path, options) {
|
|
7
|
-
const creds = await this.getCredentials();
|
|
8
|
-
const requestOptions = {
|
|
9
|
-
method: options.method,
|
|
10
|
-
body: options.body,
|
|
11
|
-
headers: options.headers,
|
|
12
|
-
host: HOST,
|
|
13
|
-
service: 'execute-api',
|
|
14
|
-
path,
|
|
15
|
-
};
|
|
16
|
-
const signedOptions = aws4.sign(requestOptions, creds);
|
|
17
|
-
const signedHeaders = signedOptions.headers;
|
|
18
|
-
const url = `https://${HOST}${path}`;
|
|
19
|
-
options.headers = signedHeaders;
|
|
20
|
-
let response;
|
|
21
|
-
try {
|
|
22
|
-
response = await fetch(url, options);
|
|
23
|
-
}
|
|
24
|
-
catch (error) {
|
|
25
|
-
throw new Error('Error (network?) making the request:', { cause: error });
|
|
26
|
-
}
|
|
27
|
-
const text = await response.text();
|
|
28
|
-
if (!response.ok) {
|
|
29
|
-
throw new HttpException(response.status, text);
|
|
30
|
-
}
|
|
31
|
-
return text;
|
|
32
|
-
}
|
|
33
|
-
async getCredentials() {
|
|
34
|
-
let identityProvider;
|
|
35
|
-
if (process.env.CI) {
|
|
36
|
-
identityProvider = fromEnv();
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
identityProvider = fromSSO();
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
42
|
-
return await identityProvider();
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
throw new Error('Error fetching credentials:', { cause: error });
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|