@agilecustoms/envctl 0.3.3 → 0.5.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 +3 -3
- package/dist/commands/index.js +2 -1
- package/dist/commands/terraform-init.js +31 -0
- package/dist/exceptions.js +18 -0
- package/dist/index.js +2 -1
- package/dist/service/DevEnvClient.js +14 -3
- package/dist/service/EnvCtl.js +3 -0
- package/dist/service/HttpClient.js +3 -6
- package/package.json +8 -2
- package/scripts/terraform-init.sh +25 -0
- package/.github/workflows/build-and-release.yml +0 -37
- package/.github/workflows/build.yml +0 -46
- package/Makefile +0 -21
- package/eslint.config.mjs +0 -34
- package/src/commands/createEphemeral.ts +0 -21
- package/src/commands/delete.ts +0 -15
- package/src/commands/index.ts +0 -3
- package/src/commands/register.ts +0 -23
- package/src/container.ts +0 -9
- package/src/exceptions.ts +0 -29
- package/src/index.ts +0 -23
- package/src/model/Environment.ts +0 -4
- package/src/model/PersonalEnvironment.ts +0 -5
- package/src/model/index.ts +0 -2
- package/src/service/DevEnvClient.ts +0 -34
- package/src/service/EnvCtl.ts +0 -57
- package/src/service/HttpClient.ts +0 -58
- package/src/service/index.ts +0 -3
- package/test/service/EnvCtl.test.ts +0 -34
- package/tsconfig.json +0 -23
- package/vitest.config.ts +0 -19
- /package/dist/commands/{createEphemeral.js → create-ephemeral.js} +0 -0
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ npm view @agilecustoms/envctl version # show latest version available (without i
|
|
|
24
24
|
2. Repository access: `envctl` only
|
|
25
25
|
|
|
26
26
|
## History/motivation
|
|
27
|
-
|
|
28
|
-
created in
|
|
27
|
+
`env-api` is a microservice hosted in 'maintenance' account and working as garbage collector: every environment first
|
|
28
|
+
created in `env-api` and then 'managed' by `env-api`: it deletes env when it is not in use anymore OR can extend lifetime.
|
|
29
29
|
Creation API yields unique ID, so you can safely manage env (delete, extend lifetime) via this ID. But creation API
|
|
30
30
|
need to be secured. There are two main use cases:
|
|
31
31
|
1. create environment from CI (mainly ephemeral envs)
|
|
@@ -35,7 +35,7 @@ I (Alex C) chosen IAM authorization as common denominator:
|
|
|
35
35
|
1. on CI - use OIDC to assume role `/ci/deployer`
|
|
36
36
|
2. on dev machine - use SSO and profile chaining to assume role `/ci/deployer`
|
|
37
37
|
|
|
38
|
-
Then as `/ci/deployer` --call-->
|
|
38
|
+
Then as `/ci/deployer` --call--> `env-api` HTTP API (exposed with API Gateway with IAM authorizer)
|
|
39
39
|
|
|
40
40
|
Now problem is: any request needs to be signed with AWS signature v4. Originally I planned to use bash scripts, but it
|
|
41
41
|
quickly became bulky and hard to maintain. Then I thought about Node.js - it is available on dev machines and
|
package/dist/commands/index.js
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
package/dist/exceptions.js
CHANGED
|
@@ -4,6 +4,16 @@ export class KnownException extends Error {
|
|
|
4
4
|
this.name = 'KnownException';
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
+
export class HttpException extends Error {
|
|
8
|
+
statusCode;
|
|
9
|
+
responseBody;
|
|
10
|
+
constructor(statusCode, responseBody) {
|
|
11
|
+
super(`Received ${statusCode} response`);
|
|
12
|
+
this.statusCode = statusCode;
|
|
13
|
+
this.responseBody = responseBody;
|
|
14
|
+
this.name = 'HttpException';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
7
17
|
export function wrap(callable) {
|
|
8
18
|
return async (...args) => {
|
|
9
19
|
let result;
|
|
@@ -14,6 +24,14 @@ export function wrap(callable) {
|
|
|
14
24
|
if (error instanceof KnownException) {
|
|
15
25
|
console.error(error.message);
|
|
16
26
|
}
|
|
27
|
+
else if (error instanceof HttpException) {
|
|
28
|
+
if (error.statusCode === 422) {
|
|
29
|
+
console.error(`Validation error: ${error.responseBody}`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.error(error.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
17
35
|
else {
|
|
18
36
|
console.error('Unknown error:', error);
|
|
19
37
|
}
|
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, ephemeral, register } from './commands/index.js';
|
|
5
|
+
import { deleteIt, ephemeral, register, terraformInit } 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();
|
|
@@ -14,4 +14,5 @@ program
|
|
|
14
14
|
register(program);
|
|
15
15
|
ephemeral(program);
|
|
16
16
|
deleteIt(program);
|
|
17
|
+
terraformInit(program);
|
|
17
18
|
program.parse(process.argv);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HttpException, KnownException } from '../exceptions.js';
|
|
1
2
|
import { HttpClient } from './HttpClient.js';
|
|
2
3
|
export class DevEnvClient {
|
|
3
4
|
httpClient;
|
|
@@ -11,9 +12,19 @@ export class DevEnvClient {
|
|
|
11
12
|
return await this.send('POST', '/ci/env', env);
|
|
12
13
|
}
|
|
13
14
|
async delete(envId) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 with key ${envId} is not found`);
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
17
28
|
}
|
|
18
29
|
async send(method, path, env) {
|
|
19
30
|
return this.httpClient.fetch(path, {
|
package/dist/service/EnvCtl.js
CHANGED
|
@@ -20,6 +20,9 @@ export class EnvCtl {
|
|
|
20
20
|
if (message.startsWith('The SSO session token') || message.startsWith('Token is expired')) {
|
|
21
21
|
throw new KnownException(error.message);
|
|
22
22
|
}
|
|
23
|
+
if (message.startsWith('ForbiddenException: No access')) {
|
|
24
|
+
throw new KnownException(`Original error: ${message}. Could be wrong sso_account_id in ~/.aws/config for the profile`);
|
|
25
|
+
}
|
|
23
26
|
}
|
|
24
27
|
throw new Error('Error fetching account', { cause: error });
|
|
25
28
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fromEnv, fromSSO } from '@aws-sdk/credential-providers';
|
|
2
2
|
import aws4 from 'aws4';
|
|
3
|
-
import {
|
|
4
|
-
const HOST = '
|
|
3
|
+
import { HttpException } from '../exceptions.js';
|
|
4
|
+
const HOST = 'env-api.maintenance.agilecustoms.com';
|
|
5
5
|
export class HttpClient {
|
|
6
6
|
async fetch(path, options) {
|
|
7
7
|
const creds = await this.getCredentials();
|
|
@@ -26,10 +26,7 @@ export class HttpClient {
|
|
|
26
26
|
}
|
|
27
27
|
const text = await response.text();
|
|
28
28
|
if (!response.ok) {
|
|
29
|
-
|
|
30
|
-
throw new KnownException(`Validation error: ${text}`);
|
|
31
|
-
}
|
|
32
|
-
throw new Error(`Received ${response.status} response: ${text}`);
|
|
29
|
+
throw new HttpException(response.status, text);
|
|
33
30
|
}
|
|
34
31
|
return text;
|
|
35
32
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilecustoms/envctl",
|
|
3
3
|
"description": "node.js CLI client for manage environments",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"author": "Alex Chekulaev",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"envctl": "dist/index.js"
|
|
9
9
|
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"scripts/",
|
|
13
|
+
"package.json"
|
|
14
|
+
],
|
|
10
15
|
"repository": {
|
|
11
16
|
"type": "git",
|
|
12
17
|
"url": "git+https://github.com/agilecustoms/envctl.git"
|
|
@@ -23,7 +28,8 @@
|
|
|
23
28
|
"run-version": "tsc --sourceMap true; npm run run -- --version",
|
|
24
29
|
"run-register": "tsc --sourceMap true; npm run run -- register --project tt --env alexc --owner laxa1986",
|
|
25
30
|
"run-ephemeral": "tsc --sourceMap true; npm run run -- ephemeral -p tt -e tempenv",
|
|
26
|
-
"run-delete": "tsc --sourceMap true; npm run run -- delete tt-alexc"
|
|
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"
|
|
27
33
|
},
|
|
28
34
|
"dependencies": {
|
|
29
35
|
"@aws-sdk/client-sts": "^3.716.0",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
KEY="${1:?}"
|
|
5
|
+
|
|
6
|
+
echo "KEY: ${KEY}"
|
|
7
|
+
|
|
8
|
+
#echo "time: $(TZ=America/New_York date +"%T") ETD ($(date -u +"%T") UTC)"
|
|
9
|
+
#echo
|
|
10
|
+
#
|
|
11
|
+
## TF stored in S3 in format: {company}-{acc-alias}-tf-state/{env} where {acc-alias} has form of {project-code}-{env-kind} like tt-dev
|
|
12
|
+
## Retrieve AWS account information
|
|
13
|
+
#acc_id=$(aws sts get-caller-identity --query "Account" --output text)
|
|
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)"
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
name: Client Build and Release
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
Build:
|
|
10
|
-
uses: ./.github/workflows/build.yml
|
|
11
|
-
with:
|
|
12
|
-
artifacts: true
|
|
13
|
-
secrets: inherit
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Release:
|
|
17
|
-
needs:
|
|
18
|
-
- Build
|
|
19
|
-
runs-on: ubuntu-latest
|
|
20
|
-
steps:
|
|
21
|
-
- name: Checkout
|
|
22
|
-
uses: actions/checkout@v4
|
|
23
|
-
with:
|
|
24
|
-
token: ${{ secrets.GH_ACTIONS_TOKEN }} # Admin PAT to bypass push protection on 'main' branch
|
|
25
|
-
|
|
26
|
-
- name: Download artifacts
|
|
27
|
-
uses: actions/download-artifact@v4
|
|
28
|
-
|
|
29
|
-
- name: Release
|
|
30
|
-
id: release
|
|
31
|
-
uses: agilecustoms/gha-release@main
|
|
32
|
-
with:
|
|
33
|
-
aws-account: ${{ vars.AWS_ACCOUNT_DIST }}
|
|
34
|
-
npmjs-token: ${{ secrets.NPMJS_TOKEN }}
|
|
35
|
-
|
|
36
|
-
- name: Summary
|
|
37
|
-
run: echo "### Released ${{ steps.release.outputs.version }} :pushpin:" >> $GITHUB_STEP_SUMMARY
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
name: Client Build
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches-ignore:
|
|
6
|
-
- main
|
|
7
|
-
workflow_call:
|
|
8
|
-
inputs:
|
|
9
|
-
artifacts:
|
|
10
|
-
required: false
|
|
11
|
-
type: boolean
|
|
12
|
-
default: false
|
|
13
|
-
description: upload artifacts or not
|
|
14
|
-
|
|
15
|
-
jobs:
|
|
16
|
-
Build:
|
|
17
|
-
runs-on: ubuntu-latest
|
|
18
|
-
steps:
|
|
19
|
-
- name: Checkout
|
|
20
|
-
uses: actions/checkout@v4
|
|
21
|
-
|
|
22
|
-
- name: Setup Node
|
|
23
|
-
uses: actions/setup-node@v4
|
|
24
|
-
with:
|
|
25
|
-
node-version: 23
|
|
26
|
-
cache: npm
|
|
27
|
-
|
|
28
|
-
- name: Install dependencies
|
|
29
|
-
run: npm ci
|
|
30
|
-
|
|
31
|
-
- name: Lint
|
|
32
|
-
run: npm run lint
|
|
33
|
-
|
|
34
|
-
- name: Test
|
|
35
|
-
run: npm run test
|
|
36
|
-
|
|
37
|
-
- name: Build
|
|
38
|
-
run: npm run build
|
|
39
|
-
|
|
40
|
-
- name: Upload artifacts
|
|
41
|
-
if: inputs.artifacts
|
|
42
|
-
uses: actions/upload-artifact@v4
|
|
43
|
-
with:
|
|
44
|
-
path: dist # take everything from dist/ folder
|
|
45
|
-
name: dist # and create artifact named dist (so later on the download action will create <repo-root>/dist/ folder)
|
|
46
|
-
# if not specify name - it will be available as 'artifact' dir
|
package/Makefile
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
_aws-login:
|
|
2
|
-
@aws sso login
|
|
3
|
-
|
|
4
|
-
# npm list --all - show dependency tree
|
|
5
|
-
app0-install-deps:
|
|
6
|
-
@npm install
|
|
7
|
-
|
|
8
|
-
app0-update-deps:
|
|
9
|
-
@npm update; npm outdated
|
|
10
|
-
|
|
11
|
-
app1-lint:
|
|
12
|
-
@npm run lint
|
|
13
|
-
|
|
14
|
-
app1-lint-fix:
|
|
15
|
-
@npm run lint:fix
|
|
16
|
-
|
|
17
|
-
app2-test:
|
|
18
|
-
@npm run test
|
|
19
|
-
|
|
20
|
-
app3-build:
|
|
21
|
-
@npm run build
|
package/eslint.config.mjs
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import tseslint from 'typescript-eslint'
|
|
2
|
-
import plugin from '@stylistic/eslint-plugin'
|
|
3
|
-
import importPlugin from 'eslint-plugin-import';
|
|
4
|
-
|
|
5
|
-
export default [
|
|
6
|
-
...tseslint.configs.recommended,
|
|
7
|
-
plugin.configs['recommended-flat'],
|
|
8
|
-
{
|
|
9
|
-
plugins: {
|
|
10
|
-
import: importPlugin,
|
|
11
|
-
},
|
|
12
|
-
rules: {
|
|
13
|
-
'@stylistic/brace-style': ['error', '1tbs'], // 'else' keyword on the same line as closing brace
|
|
14
|
-
'@stylistic/comma-dangle': 'off', // there are cases when trailing comma desired, and sometimes not
|
|
15
|
-
'@typescript-eslint/no-unused-vars': ['error', { // unused var starting from _ is ok
|
|
16
|
-
'argsIgnorePattern': '^_',
|
|
17
|
-
'destructuredArrayIgnorePattern': '^_'
|
|
18
|
-
}],
|
|
19
|
-
|
|
20
|
-
// Enforce Alphabetical Import Order and Merge Duplicate Imports
|
|
21
|
-
'import/order': ['error', {
|
|
22
|
-
'alphabetize': {'order': 'asc', 'caseInsensitive': true}
|
|
23
|
-
}],
|
|
24
|
-
'import/no-duplicates': 'error',
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
// Test-specific configuration (glob-based override)
|
|
28
|
-
{
|
|
29
|
-
files: ['**/*.test.ts', '**/*.test.tsx'],
|
|
30
|
-
rules: {
|
|
31
|
-
'@typescript-eslint/no-extra-non-null-assertion': 'off', // allow using !! initially to use with .mock.calls[0]!!
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
];
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
2
|
-
import { envCtl } from '../container.js'
|
|
3
|
-
import { wrap } from '../exceptions.js'
|
|
4
|
-
|
|
5
|
-
export function ephemeral(program: Command) {
|
|
6
|
-
program
|
|
7
|
-
.command('ephemeral')
|
|
8
|
-
.description('Create new ephemeral environment')
|
|
9
|
-
.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}')
|
|
10
|
-
.action(wrap(handler))
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type Options = {
|
|
14
|
-
key: string
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function handler(options: object): Promise<string> {
|
|
18
|
-
const { key } = options as Options
|
|
19
|
-
|
|
20
|
-
return await envCtl.createEphemeralEnv(key)
|
|
21
|
-
}
|
package/src/commands/delete.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
2
|
-
import { envCtl } from '../container.js'
|
|
3
|
-
import { wrap } from '../exceptions.js'
|
|
4
|
-
|
|
5
|
-
export function deleteIt(program: Command) {
|
|
6
|
-
program
|
|
7
|
-
.command('delete')
|
|
8
|
-
.description('Delete a development environment')
|
|
9
|
-
.argument('<string>', 'key used to create/register the environment')
|
|
10
|
-
.action(wrap(handler))
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
async function handler(key: string): Promise<void> {
|
|
14
|
-
await envCtl.delete(key)
|
|
15
|
-
}
|
package/src/commands/index.ts
DELETED
package/src/commands/register.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
2
|
-
import { envCtl } from '../container.js'
|
|
3
|
-
import { wrap } from '../exceptions.js'
|
|
4
|
-
|
|
5
|
-
export function register(program: Command) {
|
|
6
|
-
program
|
|
7
|
-
.command('register')
|
|
8
|
-
.description('Create new or update existing dev environment')
|
|
9
|
-
.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}')
|
|
10
|
-
.requiredOption('-o, --owner <owner>', 'Environment owner (GH username)')
|
|
11
|
-
.action(wrap(handler))
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type Options = {
|
|
15
|
-
key: string
|
|
16
|
-
owner: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function handler(options: object): Promise<string> {
|
|
20
|
-
const { key, owner } = options as Options
|
|
21
|
-
|
|
22
|
-
return await envCtl.registerEnv(key, owner)
|
|
23
|
-
}
|
package/src/container.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { STSClient } from '@aws-sdk/client-sts'
|
|
2
|
-
import { DevEnvClient, EnvCtl, HttpClient } from './service/index.js'
|
|
3
|
-
|
|
4
|
-
const stsClient = new STSClient()
|
|
5
|
-
const httpClient = new HttpClient()
|
|
6
|
-
const devEnvClient = new DevEnvClient(httpClient)
|
|
7
|
-
const envCtl = new EnvCtl(stsClient, devEnvClient)
|
|
8
|
-
|
|
9
|
-
export { envCtl }
|
package/src/exceptions.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export class KnownException extends Error {
|
|
2
|
-
constructor(message: string) {
|
|
3
|
-
super(message)
|
|
4
|
-
this.name = 'KnownException'
|
|
5
|
-
}
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
-
export function wrap(callable: (...args: any[]) => Promise<string | void>): (...args: any[]) => void | Promise<void> {
|
|
10
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
-
return async (...args: any[]): Promise<void> => {
|
|
12
|
-
let result: string | void
|
|
13
|
-
try {
|
|
14
|
-
result = await callable(...args)
|
|
15
|
-
} catch (error) {
|
|
16
|
-
if (error instanceof KnownException) {
|
|
17
|
-
console.error(error.message)
|
|
18
|
-
} else {
|
|
19
|
-
// print stack trace
|
|
20
|
-
console.error('Unknown error:', error)
|
|
21
|
-
}
|
|
22
|
-
process.exit(1)
|
|
23
|
-
}
|
|
24
|
-
// this is how result is returned back so it can be accessed by calling shell script
|
|
25
|
-
if (result !== undefined) {
|
|
26
|
-
console.log(result)
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from 'module'
|
|
3
|
-
import { Command } from 'commander'
|
|
4
|
-
import updateNotifier from 'update-notifier'
|
|
5
|
-
import { deleteIt, ephemeral, register } from './commands/index.js'
|
|
6
|
-
|
|
7
|
-
// it is possible to use 'import' to load JSON files, but package.json is level higher, so such trick copies 'src' in /dist :(
|
|
8
|
-
const require = createRequire(import.meta.url) // custom 'require' relative to current module’s file URL
|
|
9
|
-
const pkg = require('../package.json')
|
|
10
|
-
|
|
11
|
-
// Check for updates
|
|
12
|
-
updateNotifier({ pkg, updateCheckInterval: 0 }).notify()
|
|
13
|
-
|
|
14
|
-
const program = new Command()
|
|
15
|
-
program
|
|
16
|
-
.name('envctl') // shown when running --help: Usage: envctl [options] [command]
|
|
17
|
-
.description('CLI to manage environments')
|
|
18
|
-
.version(pkg.version)
|
|
19
|
-
register(program)
|
|
20
|
-
ephemeral(program)
|
|
21
|
-
deleteIt(program)
|
|
22
|
-
|
|
23
|
-
program.parse(process.argv)
|
package/src/model/Environment.ts
DELETED
package/src/model/index.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { Environment, PersonalEnvironment } from '../model/index.js'
|
|
2
|
-
import { HttpClient } from './HttpClient.js'
|
|
3
|
-
|
|
4
|
-
export class DevEnvClient {
|
|
5
|
-
private httpClient: HttpClient
|
|
6
|
-
|
|
7
|
-
constructor(httpClient: HttpClient) {
|
|
8
|
-
this.httpClient = httpClient
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
public async registerEnv(env: PersonalEnvironment): Promise<string> {
|
|
12
|
-
return await this.send('PUT', '/ci/env', env)
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
public async createEphemeralEnv(env: Environment): Promise<string> {
|
|
16
|
-
return await this.send('POST', '/ci/env', env)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
public async delete(envId: string): Promise<void> {
|
|
20
|
-
await this.httpClient.fetch(`/ci/env/${envId}`, {
|
|
21
|
-
method: 'DELETE'
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
private async send(method: string, path: string, env: Environment): Promise<string> {
|
|
26
|
-
return this.httpClient.fetch(path, {
|
|
27
|
-
method,
|
|
28
|
-
body: JSON.stringify(env),
|
|
29
|
-
headers: {
|
|
30
|
-
'Content-Type': 'application/json'
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
}
|
|
34
|
-
}
|
package/src/service/EnvCtl.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'
|
|
2
|
-
import type { GetCallerIdentityResponse } from '@aws-sdk/client-sts'
|
|
3
|
-
import { KnownException } from '../exceptions.js'
|
|
4
|
-
import { DevEnvClient } from './DevEnvClient.js'
|
|
5
|
-
|
|
6
|
-
export class EnvCtl {
|
|
7
|
-
private stsClient: STSClient
|
|
8
|
-
private devEnvClient: DevEnvClient
|
|
9
|
-
|
|
10
|
-
constructor(
|
|
11
|
-
stsClient: STSClient,
|
|
12
|
-
devEnvClient: DevEnvClient
|
|
13
|
-
) {
|
|
14
|
-
this.stsClient = stsClient
|
|
15
|
-
this.devEnvClient = devEnvClient
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
private async getAccount(): Promise<string> {
|
|
19
|
-
const command = new GetCallerIdentityCommand({})
|
|
20
|
-
let response: GetCallerIdentityResponse
|
|
21
|
-
try {
|
|
22
|
-
response = await this.stsClient.send(command)
|
|
23
|
-
} catch (error) {
|
|
24
|
-
// there is no type to check with instanceof, and also different errors have same .name == 'CredentialsProviderError'
|
|
25
|
-
if (error instanceof Error) {
|
|
26
|
-
const message = error.message
|
|
27
|
-
if (message.startsWith('The SSO session token') || message.startsWith('Token is expired')) {
|
|
28
|
-
throw new KnownException(error.message)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
throw new Error('Error fetching account', { cause: error })
|
|
32
|
-
}
|
|
33
|
-
return response.Account!
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
public async registerEnv(key: string, owner: string): Promise<string> {
|
|
37
|
-
const account = await this.getAccount()
|
|
38
|
-
const env = { account, key, owner }
|
|
39
|
-
return await this.devEnvClient.registerEnv(env)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
public async createEphemeralEnv(key: string): Promise<string> {
|
|
43
|
-
const account = await this.getAccount()
|
|
44
|
-
const env = { account, key }
|
|
45
|
-
return await this.devEnvClient.createEphemeralEnv(env)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Here client uses secret knowledge on how id is constructed from account and key
|
|
50
|
-
* Probably it is OK because client and server are tightly coupled
|
|
51
|
-
*/
|
|
52
|
-
public async delete(key: string): Promise<void> {
|
|
53
|
-
const account = await this.getAccount()
|
|
54
|
-
const envId = `${account}-${key}`
|
|
55
|
-
return this.devEnvClient.delete(envId)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { fromEnv, fromSSO } from '@aws-sdk/credential-providers'
|
|
2
|
-
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types'
|
|
3
|
-
import aws4 from 'aws4'
|
|
4
|
-
import type { Credentials, Request as Aws4Request } from 'aws4'
|
|
5
|
-
import { KnownException } from '../exceptions.js'
|
|
6
|
-
|
|
7
|
-
const HOST = 'dev-env.maintenance.agilecustoms.com'
|
|
8
|
-
|
|
9
|
-
export class HttpClient {
|
|
10
|
-
public async fetch(path: string, options: RequestInit): Promise<string> {
|
|
11
|
-
const creds: Credentials = await this.getCredentials()
|
|
12
|
-
|
|
13
|
-
const requestOptions: Aws4Request = {
|
|
14
|
-
method: options.method,
|
|
15
|
-
body: options.body as string | undefined,
|
|
16
|
-
headers: options.headers as Record<string, string>,
|
|
17
|
-
host: HOST,
|
|
18
|
-
service: 'execute-api',
|
|
19
|
-
path,
|
|
20
|
-
}
|
|
21
|
-
const signedOptions: Aws4Request = aws4.sign(requestOptions, creds)
|
|
22
|
-
const signedHeaders = signedOptions.headers as Record<string, string>
|
|
23
|
-
|
|
24
|
-
const url = `https://${HOST}${path}`
|
|
25
|
-
options.headers = signedHeaders
|
|
26
|
-
|
|
27
|
-
let response: Response
|
|
28
|
-
try {
|
|
29
|
-
response = await fetch(url, options)
|
|
30
|
-
} catch (error) {
|
|
31
|
-
throw new Error('Error (network?) making the request:', { cause: error })
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const text = await response.text()
|
|
35
|
-
if (!response.ok) {
|
|
36
|
-
if (response.status === 422) {
|
|
37
|
-
throw new KnownException(`Validation error: ${text}`)
|
|
38
|
-
}
|
|
39
|
-
throw new Error(`Received ${response.status} response: ${text}`)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return text
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
private async getCredentials(): Promise<AwsCredentialIdentity> {
|
|
46
|
-
let identityProvider: AwsCredentialIdentityProvider
|
|
47
|
-
if (process.env.CI) {
|
|
48
|
-
identityProvider = fromEnv()
|
|
49
|
-
} else {
|
|
50
|
-
identityProvider = fromSSO()
|
|
51
|
-
}
|
|
52
|
-
try {
|
|
53
|
-
return await identityProvider()
|
|
54
|
-
} catch (error) {
|
|
55
|
-
throw new Error('Error fetching credentials:', { cause: error })
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
package/src/service/index.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { STSClient } from '@aws-sdk/client-sts'
|
|
2
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { DevEnvClient, EnvCtl } from '../../src/service'
|
|
4
|
-
|
|
5
|
-
const stsClient = {
|
|
6
|
-
send: vi.fn(),
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const devEnvClient = {
|
|
10
|
-
registerEnv: vi.fn(),
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe('EnvCtl', () => {
|
|
14
|
-
let envCtl: EnvCtl
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
envCtl = new EnvCtl(
|
|
17
|
-
stsClient as unknown as STSClient,
|
|
18
|
-
devEnvClient as unknown as DevEnvClient
|
|
19
|
-
)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
describe('registerEnv', () => {
|
|
23
|
-
it('should call stsClient to get account and then call devEnvClient to register env', async () => {
|
|
24
|
-
stsClient.send.mockResolvedValue({ Account: 'testAccount' })
|
|
25
|
-
devEnvClient.registerEnv.mockResolvedValue('testEnvId')
|
|
26
|
-
|
|
27
|
-
const result = await envCtl.registerEnv('env', 'owner')
|
|
28
|
-
|
|
29
|
-
expect(result).toBe('testEnvId')
|
|
30
|
-
expect(stsClient.send).toHaveBeenCalled()
|
|
31
|
-
expect(devEnvClient.registerEnv).toHaveBeenCalled()
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
})
|
package/tsconfig.json
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// https://www.typescriptlang.org/tsconfig
|
|
2
|
-
{
|
|
3
|
-
"include": [
|
|
4
|
-
"src"
|
|
5
|
-
],
|
|
6
|
-
// Runtime is Node 23 (latest as Jan 2025), to be run in CI (action/setup-node) and local machines
|
|
7
|
-
"compilerOptions": {
|
|
8
|
-
// MAIN SETTINGS (based on runtime)
|
|
9
|
-
"target": "es2023", // highest available, fully supported by Node 23
|
|
10
|
-
"module": "nodenext", // preferred for modern Node projects
|
|
11
|
-
"outDir": "dist", // standard output folder for all emitted files
|
|
12
|
-
|
|
13
|
-
// DEFAULTS that are always "good to have" - expect to be the same for all projects
|
|
14
|
-
"allowJs": false, // no need to compile JS, all is TS
|
|
15
|
-
"noUncheckedIndexedAccess": true,
|
|
16
|
-
"removeComments": true,
|
|
17
|
-
"skipLibCheck": true, // skip type checking of declaration files (.d.ts) - allows quick hot reload
|
|
18
|
-
"strict": true, // enable all strict type-checking options
|
|
19
|
-
"verbatimModuleSyntax": true, // force using 'import type' for types
|
|
20
|
-
|
|
21
|
-
// ADDITIONAL SETTINGS (vary per project)
|
|
22
|
-
}
|
|
23
|
-
}
|
package/vitest.config.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
test: {
|
|
5
|
-
coverage: {
|
|
6
|
-
exclude: [
|
|
7
|
-
'**/index.ts',
|
|
8
|
-
'src/container.ts',
|
|
9
|
-
'src/model/**',
|
|
10
|
-
...coverageConfigDefaults.exclude
|
|
11
|
-
],
|
|
12
|
-
reporter: ['text'], // other: 'html', 'clover', 'json'
|
|
13
|
-
thresholds: {
|
|
14
|
-
lines: 26,
|
|
15
|
-
branches: 44,
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
})
|
|
File without changes
|