@agilecustoms/envctl 0.1.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/.github/workflows/build-and-release.yml +46 -0
- package/.github/workflows/build.yml +46 -0
- package/Makefile +28 -0
- package/README.md +35 -0
- package/dist/commands/createEphemeral.js +14 -0
- package/dist/commands/delete.js +13 -0
- package/dist/commands/index.js +3 -0
- package/dist/commands/register.js +15 -0
- package/dist/container.js +7 -0
- package/dist/exceptions.js +26 -0
- package/dist/index.js +15 -0
- package/dist/model/Environment.js +1 -0
- package/dist/model/PersonalEnvironment.js +1 -0
- package/dist/model/index.js +1 -0
- package/dist/service/DevEnvClient.js +27 -0
- package/dist/service/EnvCtl.js +43 -0
- package/dist/service/HttpClient.js +51 -0
- package/dist/service/index.js +3 -0
- package/eslint.config.mjs +34 -0
- package/package.json +45 -0
- package/src/commands/createEphemeral.ts +21 -0
- package/src/commands/delete.ts +15 -0
- package/src/commands/index.ts +3 -0
- package/src/commands/register.ts +23 -0
- package/src/container.ts +9 -0
- package/src/exceptions.ts +29 -0
- package/src/index.ts +19 -0
- package/src/model/Environment.ts +4 -0
- package/src/model/PersonalEnvironment.ts +5 -0
- package/src/model/index.ts +2 -0
- package/src/service/DevEnvClient.ts +34 -0
- package/src/service/EnvCtl.ts +57 -0
- package/src/service/HttpClient.ts +58 -0
- package/src/service/index.ts +3 -0
- package/test/service/EnvCtl.test.ts +34 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Release:
|
|
20
|
+
needs:
|
|
21
|
+
- Build
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
defaults:
|
|
24
|
+
run:
|
|
25
|
+
working-directory: client
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write
|
|
28
|
+
contents: write
|
|
29
|
+
steps:
|
|
30
|
+
- name: Checkout
|
|
31
|
+
uses: actions/checkout@v4
|
|
32
|
+
with:
|
|
33
|
+
token: ${{ secrets.GH_ACTIONS_TOKEN }} # Admin PAT to bypass push protection on 'main' branch
|
|
34
|
+
|
|
35
|
+
- name: Download artifacts
|
|
36
|
+
uses: actions/download-artifact@v4
|
|
37
|
+
|
|
38
|
+
- name: Release
|
|
39
|
+
id: release
|
|
40
|
+
uses: agilecustoms/gha-release@main
|
|
41
|
+
with:
|
|
42
|
+
aws-account: ${{ vars.AWS_ACCOUNT_DIST }}
|
|
43
|
+
npmjs-token: ${{ secrets.NPMJS_TOKEN }}
|
|
44
|
+
|
|
45
|
+
- name: Summary
|
|
46
|
+
run: echo "### Released ${{ steps.release.outputs.version }} :pushpin:" >> $GITHUB_STEP_SUMMARY
|
|
@@ -0,0 +1,46 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
|
|
22
|
+
|
|
23
|
+
# npm cache clean --force
|
|
24
|
+
# cd ~/.npm; open .
|
|
25
|
+
app4-dry-run:
|
|
26
|
+
@set -e; \
|
|
27
|
+
echo "cd in other directory, otherwise npx picks up local package :("; cd ..; \
|
|
28
|
+
npx -y --force @agilecustoms/envctl --version
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# envctl
|
|
2
|
+
- npm [@agilecustoms/envctl](https://www.npmjs.com/package/@agilecustoms/envctl)
|
|
3
|
+
- npm [agilecustoms/packages](https://www.npmjs.com/settings/agilecustoms/packages) (admin view)
|
|
4
|
+
|
|
5
|
+
## npmjs setup
|
|
6
|
+
1. Login in npmjs.com
|
|
7
|
+
2. Create organization `agilecustoms` this will create scope `@agilecustoms` (one org => exactly one scope, also scope can be created w/o org)
|
|
8
|
+
3. Go to your user > Access Tokens > Generate New Token > New Granular Access Token
|
|
9
|
+
1. Token name: `agilecustoms-ci`
|
|
10
|
+
2. Packages and scopes
|
|
11
|
+
1. Permissions: Read and write
|
|
12
|
+
2. Only select packages and scopes: `@agilecustoms`
|
|
13
|
+
3. Organizations (keep as is)
|
|
14
|
+
4. Save token in GitHub > org > Settings > Secrets and variables > Actions > Secrets > New organization secret
|
|
15
|
+
1. Name `NPMJS_TOKEN`
|
|
16
|
+
2. Repository access: `envctl` only
|
|
17
|
+
|
|
18
|
+
dev-env is a microservice hosted in 'maintenance' account and working as garbage collector: every environment first
|
|
19
|
+
created in dev-env and then 'managed' by dev-env: it deletes env when it is not in use anymore OR can extend lifetime.
|
|
20
|
+
Creation API yields unique ID, so you can safely manage env (delete, extend lifetime) via this ID. But creation API
|
|
21
|
+
need to be secured. There are two main use cases:
|
|
22
|
+
1. create environment from CI (mainly ephemeral envs)
|
|
23
|
+
2. create env from dev machine
|
|
24
|
+
|
|
25
|
+
I (Alex C) chosen IAM authorization as common denominator:
|
|
26
|
+
1. on CI - use OIDC to assume role `/ci/deployer`
|
|
27
|
+
2. on dev machine - use SSO and profile chaining to assume role `/ci/deployer`
|
|
28
|
+
|
|
29
|
+
Then as `/ci/deployer` --call--> dev-env HTTP API (exposed with API Gateway with IAM authorizer)
|
|
30
|
+
|
|
31
|
+
Now problem is: any request needs to be signed with AWS signature v4. Originally I planned to use bash scripts, but it
|
|
32
|
+
quickly became bulky and hard to maintain. Then I thought about Node.js - it is available on dev machines and
|
|
33
|
+
in GitHub actions (namely in Ubuntu runners). How to distribute it? First I thought about using `ncc` to bundle in one
|
|
34
|
+
big .js file (as I do for `gha-upload-3` and `gha-healthcheck`) but it will be hard to use on dev machine...
|
|
35
|
+
So I ended up with publishing this client as npm package in CodeArtifact and then running from anywhere with `npx`!
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { envCtl } from '../container.js';
|
|
3
|
+
import { wrap } from '../exceptions.js';
|
|
4
|
+
export function deleteIt(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('delete')
|
|
7
|
+
.description('Delete a development environment')
|
|
8
|
+
.argument('<string>', 'key used to create/register the environment')
|
|
9
|
+
.action(wrap(handler));
|
|
10
|
+
}
|
|
11
|
+
async function handler(key) {
|
|
12
|
+
await envCtl.delete(key);
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { STSClient } from '@aws-sdk/client-sts';
|
|
2
|
+
import { DevEnvClient, EnvCtl, HttpClient } from './service/index.js';
|
|
3
|
+
const stsClient = new STSClient();
|
|
4
|
+
const httpClient = new HttpClient();
|
|
5
|
+
const devEnvClient = new DevEnvClient(httpClient);
|
|
6
|
+
const envCtl = new EnvCtl(stsClient, devEnvClient);
|
|
7
|
+
export { envCtl };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class KnownException extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'KnownException';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function wrap(callable) {
|
|
8
|
+
return async (...args) => {
|
|
9
|
+
let result;
|
|
10
|
+
try {
|
|
11
|
+
result = await callable(...args);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error instanceof KnownException) {
|
|
15
|
+
console.error(error.message);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.error('Unknown error:', error);
|
|
19
|
+
}
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
if (result !== undefined) {
|
|
23
|
+
console.log(result);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { deleteIt, ephemeral, register } from './commands/index.js';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { version } = require('../package.json');
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('envctl')
|
|
10
|
+
.description('CLI to manage environments')
|
|
11
|
+
.version(version);
|
|
12
|
+
register(program);
|
|
13
|
+
ephemeral(program);
|
|
14
|
+
deleteIt(program);
|
|
15
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { HttpClient } from './HttpClient.js';
|
|
2
|
+
export class DevEnvClient {
|
|
3
|
+
httpClient;
|
|
4
|
+
constructor(httpClient) {
|
|
5
|
+
this.httpClient = httpClient;
|
|
6
|
+
}
|
|
7
|
+
async registerEnv(env) {
|
|
8
|
+
return await this.send('PUT', '/ci/env', env);
|
|
9
|
+
}
|
|
10
|
+
async createEphemeralEnv(env) {
|
|
11
|
+
return await this.send('POST', '/ci/env', env);
|
|
12
|
+
}
|
|
13
|
+
async delete(envId) {
|
|
14
|
+
await this.httpClient.fetch(`/ci/env/${envId}`, {
|
|
15
|
+
method: 'DELETE'
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async send(method, path, env) {
|
|
19
|
+
return this.httpClient.fetch(path, {
|
|
20
|
+
method,
|
|
21
|
+
body: JSON.stringify(env),
|
|
22
|
+
headers: {
|
|
23
|
+
'Content-Type': 'application/json'
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
|
|
2
|
+
import { KnownException } from '../exceptions.js';
|
|
3
|
+
import { DevEnvClient } from './DevEnvClient.js';
|
|
4
|
+
export class EnvCtl {
|
|
5
|
+
stsClient;
|
|
6
|
+
devEnvClient;
|
|
7
|
+
constructor(stsClient, devEnvClient) {
|
|
8
|
+
this.stsClient = stsClient;
|
|
9
|
+
this.devEnvClient = devEnvClient;
|
|
10
|
+
}
|
|
11
|
+
async getAccount() {
|
|
12
|
+
const command = new GetCallerIdentityCommand({});
|
|
13
|
+
let response;
|
|
14
|
+
try {
|
|
15
|
+
response = await this.stsClient.send(command);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error) {
|
|
19
|
+
const message = error.message;
|
|
20
|
+
if (message.startsWith('The SSO session token') || message.startsWith('Token is expired')) {
|
|
21
|
+
throw new KnownException(error.message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw new Error('Error fetching account', { cause: error });
|
|
25
|
+
}
|
|
26
|
+
return response.Account;
|
|
27
|
+
}
|
|
28
|
+
async registerEnv(key, owner) {
|
|
29
|
+
const account = await this.getAccount();
|
|
30
|
+
const env = { account, key, owner };
|
|
31
|
+
return await this.devEnvClient.registerEnv(env);
|
|
32
|
+
}
|
|
33
|
+
async createEphemeralEnv(key) {
|
|
34
|
+
const account = await this.getAccount();
|
|
35
|
+
const env = { account, key };
|
|
36
|
+
return await this.devEnvClient.createEphemeralEnv(env);
|
|
37
|
+
}
|
|
38
|
+
async delete(key) {
|
|
39
|
+
const account = await this.getAccount();
|
|
40
|
+
const envId = `${account}-${key}`;
|
|
41
|
+
return this.devEnvClient.delete(envId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { fromEnv, fromSSO } from '@aws-sdk/credential-providers';
|
|
2
|
+
import aws4 from 'aws4';
|
|
3
|
+
import { KnownException } from '../exceptions.js';
|
|
4
|
+
const HOST = 'dev-env.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
|
+
if (response.status === 422) {
|
|
30
|
+
throw new KnownException(`Validation error: ${text}`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Received ${response.status} response: ${text}`);
|
|
33
|
+
}
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
async getCredentials() {
|
|
37
|
+
let identityProvider;
|
|
38
|
+
if (process.env.CI) {
|
|
39
|
+
identityProvider = fromEnv();
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
identityProvider = fromSSO();
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return await identityProvider();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error('Error fetching credentials:', { cause: error });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agilecustoms/envctl",
|
|
3
|
+
"description": "node.js CLI client for manage environments",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"author": "Alex Chekulaev",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"envctl": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/agilecustoms/envctl.git"
|
|
13
|
+
},
|
|
14
|
+
"license": "UNLICENSED",
|
|
15
|
+
"_comment0": "to run via `npx` you must use `name` (with scope) and have at least one entry in `bin`. Key does not matter when run via `npx`",
|
|
16
|
+
"_comment1": "keys become CLI apps (symlinks) when install globally. ex: npm install -g @agilecustoms/envctl; ls $(which alexc-blah) >> lrwxr-xr-x 1 alexc admin 62 Dec 30 14:29 /opt/homebrew/bin/alexc-blah -> ../lib/node_modules/@agilecustoms/envctl-client/dist/index.js",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"lint": "eslint src/*.ts src/**/*.ts test/**/*.ts",
|
|
19
|
+
"lint:fix": "npm run lint -- --fix",
|
|
20
|
+
"test": "vitest run --coverage",
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"run": "node dist/index.js",
|
|
23
|
+
"run-version": "tsc --sourceMap true; npm run run -- --version",
|
|
24
|
+
"run-register": "tsc --sourceMap true; npm run run -- register --project tt --env alexc --owner laxa1986",
|
|
25
|
+
"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"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@aws-sdk/client-sts": "^3.716.0",
|
|
30
|
+
"@aws-sdk/credential-providers": "^3.716.0",
|
|
31
|
+
"aws4": "^1.13.2",
|
|
32
|
+
"commander": "^13.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@stylistic/eslint-plugin": "^4.2.0",
|
|
36
|
+
"@types/aws4": "^1.11.6",
|
|
37
|
+
"@types/node": "^22.10.2",
|
|
38
|
+
"@vitest/coverage-v8": "^3.0.2",
|
|
39
|
+
"eslint": "^9.22.0",
|
|
40
|
+
"eslint-plugin-import": "^2.31.0",
|
|
41
|
+
"typescript": "^5.7.2",
|
|
42
|
+
"typescript-eslint": "^8.8.1",
|
|
43
|
+
"vitest": "^3.0.2"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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 }
|
|
@@ -0,0 +1,29 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'module'
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import { deleteIt, ephemeral, register } from './commands/index.js'
|
|
5
|
+
|
|
6
|
+
// it is possible to use 'import' to load JSON files, but package.json is level higher, so such trick copies 'src' in /dist :(
|
|
7
|
+
const require = createRequire(import.meta.url) // custom 'require' relative to current module’s file URL
|
|
8
|
+
const { version } = require('../package.json')
|
|
9
|
+
|
|
10
|
+
const program = new Command()
|
|
11
|
+
program
|
|
12
|
+
.name('envctl') // shown when running --help: Usage: envctl [options] [command]
|
|
13
|
+
.description('CLI to manage environments')
|
|
14
|
+
.version(version)
|
|
15
|
+
register(program)
|
|
16
|
+
ephemeral(program)
|
|
17
|
+
deleteIt(program)
|
|
18
|
+
|
|
19
|
+
program.parse(process.argv)
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
})
|