@agilecustoms/envctl 1.22.0 → 1.24.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.
@@ -62,6 +62,7 @@ export class EnvApiClient {
62
62
  env.status = EnvStatus.Active;
63
63
  }
64
64
  async lockForUpdate(env) {
65
+ logger.info('Lock for update');
65
66
  const body = {
66
67
  stateFile: env.stateFile,
67
68
  lockFile: env.lockFile
@@ -64,7 +64,18 @@ export class HttpClient {
64
64
  throw new Error('Error (network?) making the request:', { cause: error });
65
65
  }
66
66
  const contentType = response.headers?.get('Content-Type') || '';
67
- const body = contentType.includes('application/json') ? await response.json() : await response.text();
67
+ let body = await response.text();
68
+ if (contentType.includes('application/json')) {
69
+ try {
70
+ body = JSON.parse(body);
71
+ }
72
+ catch (e) {
73
+ if (e instanceof SyntaxError) {
74
+ throw new Error(`Malformed json response: ${body}`, { cause: e });
75
+ }
76
+ throw new Error('Unexpected error when parsing JSON response', { cause: e });
77
+ }
78
+ }
68
79
  if (response.ok) {
69
80
  return body;
70
81
  }
@@ -72,7 +83,7 @@ export class HttpClient {
72
83
  throw new NotFoundException(body);
73
84
  }
74
85
  if (response.status === 422) {
75
- throw new BusinessException(body);
86
+ throw new BusinessException(body.message);
76
87
  }
77
88
  if (response.status === 426) {
78
89
  throw new KnownException(body.message);
@@ -13,7 +13,7 @@ export function wrap(callable) {
13
13
  else if (error instanceof AbortedException || error instanceof ExitPromptError) {
14
14
  }
15
15
  else {
16
- logger.error('Unknown error: ' + error);
16
+ logger.error('Unknown error: \n' + error.stack);
17
17
  }
18
18
  process.exit(1);
19
19
  }
@@ -3,6 +3,7 @@ export { configure } from './configure.js';
3
3
  export { createEphemeral } from './createEphemeral.js';
4
4
  export { deleteIt } from './delete.js';
5
5
  export { destroy } from './destroy.js';
6
+ export { init } from './init.js';
6
7
  export { logs } from './logs.js';
7
8
  export { plan } from './plan.js';
8
9
  export { status } from './status.js';
@@ -0,0 +1,17 @@
1
+ import { Command } from 'commander';
2
+ import { ConfigService, EnvService } from '../service/index.js';
3
+ import { _keys } from './_keys.js';
4
+ import { wrap } from './_utils.js';
5
+ export function init(program, configService, envService) {
6
+ program
7
+ .command('init')
8
+ .description('High level wrapper for terraform init')
9
+ .option('--profile <profile>', _keys.PROFILE)
10
+ .allowUnknownOption(true)
11
+ .argument('[args...]')
12
+ .action(wrap(async (tfArgs, options) => {
13
+ const { profile } = options;
14
+ configService.init(profile, false);
15
+ await envService.init(tfArgs);
16
+ }));
17
+ }
package/dist/container.js CHANGED
@@ -15,7 +15,7 @@ export function buildContainer(appVersion) {
15
15
  const localStateService = new LocalStateService();
16
16
  const nonInteractive = !process.stdout.isTTY || process.env.CI === 'true';
17
17
  const terraformAdapter = new TerraformAdapter(Date.now, configService, cli, localStateService, backends, nonInteractive);
18
- const envService = new EnvService(configService, cli, envApiClient, terraformAdapter);
18
+ const envService = new EnvService(cli, envApiClient, terraformAdapter);
19
19
  const logService = new LogService(cli, envApiClient, terraformAdapter);
20
20
  return { configService, envService, logService };
21
21
  }
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 { configure, createEphemeral, deleteIt, apply, destroy, logs, plan, status } from './commands/index.js';
5
+ import { configure, createEphemeral, deleteIt, apply, destroy, logs, plan, status, init } from './commands/index.js';
6
6
  import { buildContainer } from './container.js';
7
7
  const require = createRequire(import.meta.url);
8
8
  const pkg = require('../package.json');
@@ -23,6 +23,7 @@ configure(program, configService);
23
23
  createEphemeral(program, configService, envService);
24
24
  deleteIt(program, configService, envService);
25
25
  destroy(program, configService, envService);
26
+ init(program, configService, envService);
26
27
  logs(program, configService, logService);
27
28
  plan(program, configService, envService);
28
29
  status(program, configService, envService);
@@ -7,7 +7,7 @@ export class BaseService {
7
7
  getKey(key) {
8
8
  if (!key) {
9
9
  logger.info('Key is not provided, inferring from state file');
10
- key = this.terraformAdapter.getTerraformBackend().getKey();
10
+ key = this.terraformAdapter.getKey();
11
11
  }
12
12
  return key;
13
13
  }
@@ -5,13 +5,12 @@ import { logger } from '../logger.js';
5
5
  import { EnvStatus } from '../model/index.js';
6
6
  import { toLocalTime } from '../util.js';
7
7
  import { BaseService } from './BaseService.js';
8
+ import { ApplyMode, getApplyMode } from './TerraformAdapter.js';
8
9
  export class EnvService extends BaseService {
9
- configService;
10
10
  cli;
11
11
  envApi;
12
- constructor(configService, cli, envApi, terraformAdapter) {
12
+ constructor(cli, envApi, terraformAdapter) {
13
13
  super(terraformAdapter);
14
- this.configService = configService;
15
14
  this.cli = cli;
16
15
  this.envApi = envApi;
17
16
  }
@@ -34,10 +33,7 @@ export class EnvService extends BaseService {
34
33
  }
35
34
  }
36
35
  }
37
- async tryGetEnv(key) {
38
- if (this.configService.getApiKey() === undefined) {
39
- return null;
40
- }
36
+ async getEnv(key) {
41
37
  logger.info(`Check if env ${key} already exists`);
42
38
  const env = await this.envApi.get(key);
43
39
  if (env) {
@@ -48,27 +44,19 @@ export class EnvService extends BaseService {
48
44
  }
49
45
  return env;
50
46
  }
47
+ async init(tfArgs) {
48
+ await this.terraformAdapter.init(tfArgs);
49
+ await this.terraformAdapter.lockProviders();
50
+ }
51
51
  async plan(tfArgs) {
52
- const key = this.terraformAdapter.getTerraformBackend().getKey();
53
- await this.lockTerraform(null, true);
52
+ this.terraformAdapter.validateAndGetStateFile();
53
+ const key = this.terraformAdapter.getKey();
54
54
  await this.terraformAdapter.plan(key, tfArgs);
55
55
  }
56
56
  async createEphemeral(key) {
57
57
  const createEnv = { key };
58
58
  return await this.envApi.createEphemeral(createEnv);
59
59
  }
60
- async lockTerraform(env, newEnv) {
61
- logger.info('Lock providers');
62
- const res = await this.terraformAdapter.lock(newEnv);
63
- if (env !== null) {
64
- if (res.lockFile) {
65
- env.lockFile = res.lockFile;
66
- }
67
- if (res.stateFile) {
68
- env.stateFile = res.stateFile;
69
- }
70
- }
71
- }
72
60
  checkDir(env) {
73
61
  if (env.ephemeral)
74
62
  return;
@@ -80,16 +68,18 @@ export class EnvService extends BaseService {
80
68
  }
81
69
  }
82
70
  async apply(tfArgs) {
83
- const key = this.terraformAdapter.getTerraformBackend().getKey();
84
- let env = await this.tryGetEnv(key);
71
+ const { stateFile, stateUpdated } = this.terraformAdapter.validateAndGetStateFile();
72
+ const key = this.terraformAdapter.getKey();
73
+ const { lockFile, lockUpdated } = this.terraformAdapter.getLockFile();
74
+ const applyMode = getApplyMode(tfArgs);
75
+ let env = await this.getEnv(key);
85
76
  if (env === null) {
86
77
  const dir = this.cli.getDir();
87
- const createEnv = { key, dir };
88
- await this.lockTerraform(createEnv, true);
78
+ const createEnv = { key, dir, stateFile, lockFile };
89
79
  logger.info('Creating env metadata');
90
80
  env = await this.envApi.create(createEnv);
91
81
  await this.runApply(env, tfArgs);
92
- return;
82
+ return env;
93
83
  }
94
84
  this.checkDir(env);
95
85
  if (env.status === EnvStatus.Deleting) {
@@ -101,28 +91,41 @@ export class EnvService extends BaseService {
101
91
  Check logs with 'envctl logs', address the issue and re-run 'envctl delete ${env.key}'`);
102
92
  }
103
93
  const status = env.status;
104
- if (status === EnvStatus.Init || status === EnvStatus.Active) {
105
- const newEnv = status === EnvStatus.Init;
106
- await this.lockTerraform(env, newEnv);
107
- logger.info('Will lock for update and run terraform apply (to update resources)');
94
+ if (status === EnvStatus.Init) {
95
+ if (applyMode === ApplyMode.Default) {
96
+ throw new KnownException('Either run apply with planfile or with flag -auto-approve');
97
+ }
98
+ env.stateFile = stateFile;
99
+ env.lockFile = lockFile;
108
100
  await this.envApi.lockForUpdate(env);
109
101
  await this.runApply(env, tfArgs);
110
- return;
102
+ return env;
103
+ }
104
+ if (stateUpdated) {
105
+ env.stateFile = stateFile;
106
+ }
107
+ if (lockUpdated) {
108
+ env.lockFile = lockFile;
111
109
  }
112
- await this.runApply(env, tfArgs);
110
+ if (status === EnvStatus.Active || stateUpdated || lockUpdated) {
111
+ await this.envApi.lockForUpdate(env);
112
+ }
113
+ return await this.runApply(env, tfArgs);
113
114
  }
114
115
  async runApply(env, tfArgs) {
116
+ this.terraformAdapter.saveHashes();
115
117
  logger.info('Deploying resources');
116
118
  await this.terraformAdapter.apply(env, tfArgs);
117
119
  logger.info('Activating env (to finish creation)');
118
120
  await this.envApi.activate(env);
121
+ return env;
119
122
  }
120
123
  async delete(force, key) {
121
124
  key = this.getKey(key);
122
125
  await this.envApi.delete(key, force);
123
126
  }
124
127
  async destroy(tfArgs, force) {
125
- const key = this.terraformAdapter.getTerraformBackend().getKey();
128
+ const key = this.terraformAdapter.getKey();
126
129
  const env = await this.get(key);
127
130
  if (env.status === EnvStatus.Init) {
128
131
  logger.info(`Env ${env.key} status is INIT - no resources, nothing to destroy, just deleting metadata`);
@@ -16,6 +16,35 @@ export const RETRYABLE_ERRORS = [
16
16
  function hash(data) {
17
17
  return createHash('sha256').update(data).digest('hex');
18
18
  }
19
+ export var ApplyMode;
20
+ (function (ApplyMode) {
21
+ ApplyMode["Plan"] = "PLAN";
22
+ ApplyMode["AutoApprove"] = "AUTO_APPROVE";
23
+ ApplyMode["Default"] = "DEFAULT";
24
+ })(ApplyMode || (ApplyMode = {}));
25
+ export function getApplyMode(tfArgs) {
26
+ let hasAutoApprove = false;
27
+ for (let i = 0; i < tfArgs.length; i++) {
28
+ const arg = tfArgs[i];
29
+ if (arg === '-auto-approve') {
30
+ hasAutoApprove = true;
31
+ continue;
32
+ }
33
+ if (arg.startsWith('-')) {
34
+ if (!arg.includes('=')
35
+ && i + 1 < tfArgs.length
36
+ && !tfArgs[i + 1].startsWith('-')) {
37
+ i++;
38
+ }
39
+ continue;
40
+ }
41
+ return ApplyMode.Plan;
42
+ }
43
+ if (hasAutoApprove) {
44
+ return ApplyMode.AutoApprove;
45
+ }
46
+ return ApplyMode.Default;
47
+ }
19
48
  export class TerraformAdapter {
20
49
  now;
21
50
  configService;
@@ -35,19 +64,7 @@ export class TerraformAdapter {
35
64
  return acc;
36
65
  }, new Map());
37
66
  }
38
- getLockFile() {
39
- const lockPath = path.join(process.cwd(), '.terraform.lock.hcl');
40
- if (!fs.existsSync(lockPath)) {
41
- throw new KnownException(`Terraform lock file not found at: ${lockPath}`);
42
- }
43
- try {
44
- return fs.readFileSync(lockPath, 'utf8');
45
- }
46
- catch (err) {
47
- throw new KnownException(`Failed to read terraform lock file: ${lockPath}`, { cause: err });
48
- }
49
- }
50
- getTerraformBackend() {
67
+ getBackend() {
51
68
  if (this.backend) {
52
69
  return this.backend;
53
70
  }
@@ -80,37 +97,45 @@ export class TerraformAdapter {
80
97
  this.backend = backend;
81
98
  return backend;
82
99
  }
83
- async lock(newEnv) {
84
- const res = {};
85
- const stateFileContent = this.getTerraformBackend().validateAndGetStateFileContent();
86
- const stateHash = hash(stateFileContent);
100
+ getKey() {
101
+ return this.getBackend().getKey();
102
+ }
103
+ validateAndGetStateFile() {
104
+ const fileContent = this.getBackend().validateAndGetStateFileContent();
87
105
  const config = this.localStateService.load();
88
- if (config.stateHash !== stateHash || newEnv) {
89
- config.stateHash = stateHash;
90
- res.stateFile = stateFileContent;
106
+ const fileHash = hash(fileContent);
107
+ const stateUpdated = config.stateHash !== fileHash;
108
+ config.stateHash = fileHash;
109
+ return { stateFile: fileContent, stateUpdated };
110
+ }
111
+ getLockFile() {
112
+ const lockPath = path.join(process.cwd(), '.terraform.lock.hcl');
113
+ if (!fs.existsSync(lockPath)) {
114
+ throw new KnownException(`Terraform lock file not found at: ${lockPath}`);
91
115
  }
92
- const linuxArm64 = process.platform === 'linux' && process.arch === 'arm64';
93
- if (!linuxArm64 || newEnv) {
94
- let lockFileContent = this.getLockFile();
95
- if (!linuxArm64) {
96
- const lockFileHash = hash(lockFileContent);
97
- if (config.lockHash !== lockFileHash) {
98
- await this.lockProviders();
99
- lockFileContent = this.getLockFile();
100
- config.lockHash = hash(lockFileContent);
101
- res.lockFile = lockFileContent;
102
- }
103
- }
104
- if (newEnv) {
105
- res.lockFile = lockFileContent;
106
- }
116
+ let fileContent;
117
+ try {
118
+ fileContent = fs.readFileSync(lockPath, 'utf8');
119
+ }
120
+ catch (cause) {
121
+ throw new KnownException(`Failed to read terraform lock file: ${lockPath}`, { cause });
107
122
  }
123
+ const config = this.localStateService.load();
124
+ const fileHash = hash(fileContent);
125
+ const lockUpdated = config.lockHash !== fileHash;
126
+ config.stateHash = fileHash;
127
+ return { lockFile: fileContent, lockUpdated };
128
+ }
129
+ saveHashes() {
130
+ const config = this.localStateService.load();
108
131
  this.localStateService.save(config);
109
- return res;
110
132
  }
111
133
  async lockProviders() {
112
- logger.info('Update lock file');
113
- await this.cli.run('terraform', ['providers', 'lock', '-platform=linux_arm64']);
134
+ const linuxArm64 = process.platform === 'linux' && process.arch === 'arm64';
135
+ if (!linuxArm64) {
136
+ logger.info('Lock providers');
137
+ await this.cli.run('terraform', ['providers', 'lock', '-platform=linux_arm64']);
138
+ }
114
139
  }
115
140
  printTime() {
116
141
  const now = new Date();
@@ -179,6 +204,18 @@ export class TerraformAdapter {
179
204
  });
180
205
  return result;
181
206
  }
207
+ async init(args) {
208
+ logger.info('Running: terraform init ' + args.join(' ') + '\n');
209
+ try {
210
+ await this.cli.run('terraform', ['init', ...args], { interactive: true });
211
+ }
212
+ catch (error) {
213
+ if (!(error instanceof ProcessException)) {
214
+ throw error;
215
+ }
216
+ throw new KnownException(`terraform init failed with code ${error.code}:\n${error.message}`, { cause: error });
217
+ }
218
+ }
182
219
  async plan(key, args) {
183
220
  args = this.tfArgs(key, args);
184
221
  await this._plan(args, 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilecustoms/envctl",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "description": "node.js CLI client for manage environments",
5
5
  "keywords": [
6
6
  "terraform wrapper",
@@ -44,36 +44,44 @@
44
44
  "-d1-": "",
45
45
  "d1-desc": "dev env (fully fledged ~/.envctl/default.json)",
46
46
  "d1": "CWD=../tt-core npm run it --",
47
- "d1-init": "cd ../tt-core && terraform init -upgrade -backend-config=key=d1 -reconfigure",
47
+ "d1-init": " npm run d1 -- init -reconfigure -upgrade -backend-config=key=d1",
48
48
  "d1-status": "npm run d1 -- status --verbose",
49
- "d1-apply": " npm run d1 -- apply --auto-approve",
49
+ "d1-apply": " npm run d1 -- apply --auto-approve -var=env_size=min",
50
50
  "d1-delete": "npm run d1 -- delete",
51
+ "d1-logs": " npm run d1 -- logs",
51
52
  "-d2-": "",
52
53
  "d2-desc": "dev env destroy",
53
54
  "d2": "CWD=../tt-core npm run it --",
54
- "d2-init": "cd ../tt-core && terraform init -upgrade -backend-config=key=d2 -reconfigure",
55
+ "d2-init": " npm run d2 -- init -reconfigure -upgrade -backend-config=key=d2",
55
56
  "d2-status": " npm run d2 -- status",
56
- "d2-apply": " npm run d2 -- apply --auto-approve -var=env_size=min",
57
+ "d2-apply": " npm run d2 -- apply --auto-approve",
57
58
  "d2-destroy": "npm run d2 -- destroy -var=env_size=min --auto-approve",
58
59
  "-d3-": "",
59
60
  "d3-desc": "dev env plan apply",
60
61
  "d3": "CWD=../tt-core npm run it --",
61
- "d3-init": "cd ../tt-core && terraform init -upgrade -backend-config=key=d3 -reconfigure",
62
+ "d3-init": " npm run d3 -- init -reconfigure -upgrade -backend-config=key=d3",
62
63
  "d3-plan": " npm run d3 -- plan -var=env_size=min -var=env=d3 -out=plan",
63
64
  "d3-apply": " npm run d3 -- apply plan",
64
65
  "d3-delete": "npm run d3 -- delete",
66
+ "-d4-": "",
67
+ "d4-desc": "terraform plan and then envctl apply",
68
+ "d4": "CWD=../tt-core npm run it --",
69
+ "d4-init": "cd ../tt-core && rm .terraform.lock.hcl && terraform init -reconfigure -upgrade -backend-config=key=d4",
70
+ "d4-plan": "cd ../tt-core && terraform plan -var=env_size=min -var=env=d4 -out=plan",
71
+ "d4-apply": " npm run d4 -- apply plan",
72
+ "d4-delete": "npm run d4 -- delete",
65
73
  "-e1-": "",
66
74
  "e1-desc": "ephemeral env in CI (must have ENVCTL_API_KEY_ in env vars)",
67
75
  "e1": "CWD=../tt-core CI=true ENVCTL_HOME=non-existing ENVCTL_API_KEY=\"$ENVCTL_API_KEY_\" npm run it --",
68
76
  "e1-create": "npm run e1 -- create-ephemeral e1",
69
- "e1-init": "cd ../tt-core && terraform init -upgrade -backend-config=key=e1 -reconfigure",
77
+ "e1-init": " npm run e1 -- init -reconfigure -upgrade -backend-config=key=e1",
70
78
  "e1-plan": " npm run e1 -- plan -var=env_size=min -var=env=e1 -out=plan",
71
79
  "e1-apply": " npm run e1 -- apply plan",
72
80
  "e1-delete": "npm run e1 -- delete",
73
81
  "-tt-": "",
74
82
  "tt-desc": "deploy TT project from local machine",
75
83
  "tt": "CWD=../tt-gitops AWS_PROFILE=ac-tt-dev-deployer npm run it --",
76
- "run-init": "cd ../tt-gitops && terraform init -upgrade -backend-config=key=laxa1986 -reconfigure",
84
+ "run-init": " npm run tt -- init -reconfigure -upgrade -backend-config=key=laxa1986",
77
85
  "run-status": " npm run tt -- status",
78
86
  "run-apply": " npm run tt -- apply --auto-approve -var=env_size=min -var-file=versions.tfvars",
79
87
  "run-delete": " npm run tt -- delete"