From 8fa43286f1199b1a6082378eead75b9629a25102 Mon Sep 17 00:00:00 2001 From: ingalls Date: Wed, 25 Mar 2026 22:00:34 -0600 Subject: [PATCH 01/12] Upgrade to Typescript --- .gitignore | 3 +- cli.js | 186 - cli.ts | 230 ++ eslint.config.js | 21 +- lib/artifacts.js | 15 - lib/artifacts.ts | 10 + lib/artifacts/docker.js | 104 - lib/artifacts/docker.ts | 97 + lib/artifacts/s3.js | 88 - lib/artifacts/s3.ts | 83 + lib/{cancel.js => cancel.ts} | 10 +- lib/{commands.js => commands.ts} | 17 +- lib/context.js | 251 -- lib/context.ts | 242 ++ lib/{create.js => create.ts} | 8 +- lib/{delete.js => delete.ts} | 8 +- lib/{env.js => env.ts} | 22 +- lib/exec.js | 135 - lib/exec.ts | 130 + lib/gh.js | 249 -- lib/gh.ts | 246 ++ lib/git.js | 114 - lib/git.ts | 76 + lib/{help.js => help.ts} | 15 +- lib/info.js | 117 - lib/info.ts | 121 + lib/init.js | 114 - lib/init.ts | 119 + lib/{json.js => json.ts} | 23 +- lib/{list.js => list.ts} | 63 +- lib/tags.js | 48 - lib/tags.ts | 47 + lib/types.ts | 93 + lib/{update.js => update.ts} | 8 +- package-lock.json | 5565 +++++++++++++----------------- package.json | 23 +- test/context.test.js | 229 -- test/context.test.ts | 198 ++ tsconfig.json | 30 + 39 files changed, 4320 insertions(+), 4838 deletions(-) delete mode 100755 cli.js create mode 100644 cli.ts delete mode 100644 lib/artifacts.js create mode 100644 lib/artifacts.ts delete mode 100644 lib/artifacts/docker.js create mode 100644 lib/artifacts/docker.ts delete mode 100644 lib/artifacts/s3.js create mode 100644 lib/artifacts/s3.ts rename lib/{cancel.js => cancel.ts} (72%) rename lib/{commands.js => commands.ts} (71%) delete mode 100644 lib/context.js create mode 100644 lib/context.ts rename lib/{create.js => create.ts} (83%) rename lib/{delete.js => delete.ts} (83%) rename lib/{env.js => env.ts} (65%) delete mode 100644 lib/exec.js create mode 100644 lib/exec.ts delete mode 100644 lib/gh.js create mode 100644 lib/gh.ts delete mode 100644 lib/git.js create mode 100644 lib/git.ts rename lib/{help.js => help.ts} (79%) mode change 100755 => 100644 delete mode 100644 lib/info.js create mode 100644 lib/info.ts delete mode 100644 lib/init.js create mode 100644 lib/init.ts rename lib/{json.js => json.ts} (61%) rename lib/{list.js => list.ts} (58%) delete mode 100644 lib/tags.js create mode 100644 lib/tags.ts create mode 100644 lib/types.ts rename lib/{update.js => update.ts} (83%) delete mode 100644 test/context.test.js create mode 100644 test/context.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 3c3629e..b947077 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules +node_modules/ +dist/ diff --git a/cli.js b/cli.js deleted file mode 100755 index bd7f3d2..0000000 --- a/cli.js +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env node - -import fs from 'node:fs'; -import path from 'node:path'; -import minimist from 'minimist'; -import inquirer from 'inquirer'; -import Git from './lib/git.js'; -import Help from './lib/help.js'; - -import GH from './lib/gh.js'; -import Context from './lib/context.js'; -import artifacts from './lib/artifacts.js'; -import Tags from './lib/tags.js'; -import mode from './lib/commands.js'; - -const argv = minimist(process.argv, { - boolean: ['help', 'version', 'debug', 'force'], - string: ['profile', 'region', 'template', 'name'], - alias: { - version: 'v' - } -}); - -if (argv.version) { - console.log('openaddresses-deploy@' + JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url))).version); - process.exit(0); -} - -if (!argv._[2] || argv._[2] === 'help' || (!argv._[2] && argv.help)) Help.main(); - -const command = argv._[2]; - -if (mode[command] && argv.help) { - mode[command].help(); - process.exit(0); -} else if (argv.help) { - console.error('Subcommand not found!'); - process.exit(1); -} - -try { - await main(); -} catch (err) { - console.error(`Unknown Error: ${err.message}`); - if (argv.debug) throw err; -} - -async function main() { - if (['create', 'update', 'delete', 'cancel'].indexOf(command) > -1) { - const context = await Context.generate(argv); - - if (!argv._[3] && !argv.name) { - console.error(`Stack name required: run deploy ${command} --help`); - process.exit(1); - } - - const gh = new GH(context); - - // Ensure config & template buckets exist - await mode.init.bucket(context); - - if (['create', 'update'].includes(command)) { - if (Git.uncommitted()) { - const res = await inquirer.prompt([{ - type: 'boolean', - name: 'uncommitted', - default: 'N', - message: 'You have uncommitted changes! Continue? (y/N)' - }]); - - if (res.uncommitted.toLowerCase() !== 'y') return; - } - - if (!Git.pushed()) { - const res = await inquirer.prompt([{ - type: 'boolean', - name: 'unpushed', - default: 'N', - message: 'You have commits that haven\'t been pushed! Continue? (y/N)' - }]); - - if (res.unpushed.toLowerCase() !== 'y') return; - } - - try { - await artifacts(context); - } catch (err) { - console.error(`Artifacts Check Failed: ${err.message}`); - if (argv.debug) throw err; - process.exit(1); - } - - if (context.github) await gh.deployment(argv._[3]); - - if (context.tags && ['create', 'update'].includes(command)) { - let existingTemplate = null; - - if (command === 'update') existingTemplate = await context.cfn.lookup.info(`${context.repo}-${context.name}`, context.region, true, false); - - context.cfn.commands.config.tags = await Tags.request(context, existingTemplate); - } - } - - const template = await context.cfn.template.read(new URL(path.resolve(process.cwd(), context.template), 'file://')); - const cf_path = `/tmp/${hash()}.json`; - - fs.writeFileSync(cf_path, JSON.stringify(template.body, null, 4)); - - const parameters = new Map([ - ['GitSha', context.sha] - ]); - if (command === 'create') { - try { - await context.cfn.commands.create(context.name, cf_path, { parameters }); - - fs.unlinkSync(cf_path); - - if (context.github) await gh.deployment(argv._[3], true); - } catch (err) { - console.error(`Create failed: ${err.message}`); - if (context.github) await gh.deployment(argv._[3], false); - if (argv.debug) throw err; - } - } else if (command === 'update') { - try { - await context.cfn.commands.update(context.name, cf_path, { parameters }); - - fs.unlinkSync(cf_path); - - await gh.deployment(argv._[3], true); - } catch (err) { - console.error(`Update failed: ${err.message}`); - - if (err && context.github && err.execution === 'UNAVAILABLE' && err.status === 'FAILED') { - await gh.deployment(argv._[3], true); - } else if (context.github) { - await gh.deployment(argv._[3], false); - } - - if (argv.debug) throw err; - } - } else if (command === 'delete') { - try { - await context.cfn.commands.delete(context.name); - fs.unlinkSync(cf_path); - } catch (err) { - console.error(`Delete failed: ${err.message}`); - if (argv.debug) throw err; - } - } else if (command === 'cancel') { - try { - await context.cfn.commands.cancel(context.name); - fs.unlinkSync(cf_path); - - if (context.github) await gh.deployment(argv._[3], false); - } catch (err) { - console.error(`Cancel failed: ${err.message}`); - if (argv.debug) throw err; - } - } - } else if (mode[command]) { - if (['init'].includes(command)) { - mode[command].main(process.argv); - } else if (['env'].includes(command)) { - argv.template = false; - const context = await Context.generate(argv); - mode[command].main(context, process.argv); - } else { - const context = await Context.generate(argv); - - try { - await mode[command].main(context, process.argv); - } catch (err) { - console.error(`Command failed: ${err.message}`); - if (argv.debug) throw err; - } - } - } else { - console.error('Subcommand not found!'); - process.exit(1); - } -} - -function hash() { - return Math.random().toString(36).substring(2, 15); -} diff --git a/cli.ts b/cli.ts new file mode 100644 index 0000000..cc07486 --- /dev/null +++ b/cli.ts @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import inquirer from 'inquirer'; +import minimist from 'minimist'; +import artifacts from './lib/artifacts.js'; +import mode from './lib/commands.js'; +import Context from './lib/context.js'; +import GH from './lib/gh.js'; +import Git from './lib/git.js'; +import Help from './lib/help.js'; +import Tags from './lib/tags.js'; +import type { DeployArgv } from './lib/types.js'; + +const argv = minimist(process.argv, { + boolean: ['help', 'version', 'debug', 'force'], + string: ['profile', 'region', 'template', 'name'], + alias: { + version: 'v' + } +}) as DeployArgv; + +if (argv.version) { + const packageJson = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }; + console.log(`openaddresses-deploy@${packageJson.version}`); + process.exit(0); +} + +if (!argv._[2] || argv._[2] === 'help' || (!argv._[2] && argv.help)) { + Help.main(); +} + +const command = String(argv._[2]); + +if (mode[command] && argv.help) { + mode[command].help(); + process.exit(0); +} else if (argv.help) { + console.error('Subcommand not found!'); + process.exit(1); +} + +try { + await main(); +} catch (error) { + const err = asError(error); + console.error(`Unknown Error: ${err.message}`); + if (argv.debug) { + throw err; + } +} + +async function main(): Promise { + if (['create', 'update', 'delete', 'cancel'].includes(command)) { + const context = await Context.generate(argv); + + if (!argv._[3] && !argv.name) { + console.error(`Stack name required: run deploy ${command} --help`); + process.exit(1); + } + + const gh = new GH(context); + + await mode.init.bucket?.(context); + + if (['create', 'update'].includes(command)) { + if (Git.uncommitted()) { + const response = await inquirer.prompt<{ uncommitted: boolean }>([{ + type: 'confirm', + name: 'uncommitted', + default: false, + message: 'You have uncommitted changes. Continue?' + }]); + + if (!response.uncommitted) { + return; + } + } + + if (!Git.pushed()) { + const response = await inquirer.prompt<{ unpushed: boolean }>([{ + type: 'confirm', + name: 'unpushed', + default: false, + message: 'You have commits that have not been pushed. Continue?' + }]); + + if (!response.unpushed) { + return; + } + } + + try { + await artifacts(context); + } catch (error) { + const err = asError(error); + console.error(`Artifacts Check Failed: ${err.message}`); + if (argv.debug) { + throw err; + } + process.exit(1); + } + + if (context.github) { + await gh.deployment(String(argv._[3] ?? context.name)); + } + + if (context.tags.length > 0) { + let existingTemplate = null; + + if (command === 'update') { + existingTemplate = await context.cfn.lookup.info(`${context.repo}-${context.name}`, context.region, true, false); + } + + context.cfn.commands.config.tags = await Tags.request(context, existingTemplate); + } + } + + if (!context.template) { + throw new Error('CloudFormation template is required for this command'); + } + + const template = await context.cfn.template.read(new URL(path.resolve(process.cwd(), context.template), 'file://')); + const cloudFormationPath = `/tmp/${hash()}.json`; + + fs.writeFileSync(cloudFormationPath, JSON.stringify(template.body, null, 4)); + + const parameters = new Map([ + ['GitSha', context.sha] + ]); + + if (command === 'create') { + await runDeploymentCommand('Create failed', async () => { + await context.cfn.commands.create(context.name, cloudFormationPath, { parameters }); + fs.unlinkSync(cloudFormationPath); + + if (context.github) { + await gh.deployment(String(argv._[3] ?? context.name), true); + } + }); + } else if (command === 'update') { + await runDeploymentCommand('Update failed', async () => { + await context.cfn.commands.update(context.name, cloudFormationPath, { parameters }); + fs.unlinkSync(cloudFormationPath); + + if (context.github) { + await gh.deployment(String(argv._[3] ?? context.name), true); + } + }, async (error) => { + if (!context.github) { + return; + } + + const err = error as { execution?: string; status?: string }; + if (err.execution === 'UNAVAILABLE' && err.status === 'FAILED') { + await gh.deployment(String(argv._[3] ?? context.name), true); + } else { + await gh.deployment(String(argv._[3] ?? context.name), false); + } + }); + } else if (command === 'delete') { + await runDeploymentCommand('Delete failed', async () => { + await context.cfn.commands.delete(context.name); + fs.unlinkSync(cloudFormationPath); + }); + } else if (command === 'cancel') { + await runDeploymentCommand('Cancel failed', async () => { + await context.cfn.commands.cancel(context.name); + fs.unlinkSync(cloudFormationPath); + + if (context.github) { + await gh.deployment(String(argv._[3] ?? context.name), false); + } + }); + } + } else if (mode[command]) { + if (command === 'init') { + await mode[command].main?.(process.argv); + } else if (command === 'env') { + argv.template = false; + const context = await Context.generate(argv); + await mode[command].main?.(context, process.argv); + } else { + const context = await Context.generate(argv); + + try { + await mode[command].main?.(context, process.argv); + } catch (error) { + const err = asError(error); + console.error(`Command failed: ${err.message}`); + if (argv.debug) { + throw err; + } + } + } + } else { + console.error('Subcommand not found!'); + process.exit(1); + } +} + +async function runDeploymentCommand( + message: string, + run: () => Promise, + onError?: (_error: unknown) => Promise +): Promise { + try { + await run(); + } catch (error) { + const err = asError(error); + console.error(`${message}: ${err.message}`); + + if (onError) { + await onError(error); + } + + if (argv.debug) { + throw err; + } + } +} + +function hash(): string { + return Math.random().toString(36).substring(2, 15); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/eslint.config.js b/eslint.config.js index c9ffcae..e43f810 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,11 +1,21 @@ import js from "@eslint/js"; import nodePlugin from "eslint-plugin-n"; +import tseslint from "typescript-eslint"; export default [ js.configs.recommended, nodePlugin.configs["flat/recommended-module"], + ...tseslint.configs.recommended, { + "files": ["**/*.ts"], + "languageOptions": { + "parserOptions": { + "project": "./tsconfig.json" + } + }, "rules": { + "@typescript-eslint/no-unused-vars": "off", + "n/hashbang": "off", "n/no-process-exit": "warn", "no-console": 0, "arrow-parens": [ "error", "always" ], @@ -17,12 +27,13 @@ export default [ "eol-last": "error", "eqeqeq": [ "error", "smart" ], "indent": [ "error", 4, { "SwitchCase": 1 } ], + "@typescript-eslint/no-explicit-any": "off", + "no-unused-vars": "off", "no-confusing-arrow": [ "error", { "allowParens": false } ], "no-extend-native": "error", "no-mixed-spaces-and-tabs": "error", "func-call-spacing": [ "error", "never" ], "no-trailing-spaces": "error", - "no-unused-vars": "error", "no-use-before-define": [ "error", "nofunc" ], "object-curly-spacing": [ "error", "always" ], "prefer-arrow-callback": "error", @@ -33,7 +44,13 @@ export default [ "keyword-spacing": [ "error", { "before": true, "after": true } ], "template-curly-spacing": [ "error", "never" ], "semi-spacing": "error", - "strict": "error", + "strict": "error" + } + }, + { + "files": ["test/**/*.ts"], + "rules": { + "n/no-unpublished-import": "off" } } ] diff --git a/lib/artifacts.js b/lib/artifacts.js deleted file mode 100644 index 6d9a5ac..0000000 --- a/lib/artifacts.js +++ /dev/null @@ -1,15 +0,0 @@ -import docker from './artifacts/docker.js'; -import s3 from './artifacts/s3.js'; - -/** - * Check if desired artifacts are present before deploying - * - * @param {Context} context Context - */ -export default async function check(context) { - await docker(context); - await s3(context); - - return true; -} - diff --git a/lib/artifacts.ts b/lib/artifacts.ts new file mode 100644 index 0000000..04c2cdb --- /dev/null +++ b/lib/artifacts.ts @@ -0,0 +1,10 @@ +import docker from './artifacts/docker.js'; +import s3 from './artifacts/s3.js'; +import type { DeployContext } from './types.js'; + +export default async function check(context: DeployContext): Promise { + await docker(context); + await s3(context); + + return true; +} diff --git a/lib/artifacts/docker.js b/lib/artifacts/docker.js deleted file mode 100644 index 385b6ee..0000000 --- a/lib/artifacts/docker.js +++ /dev/null @@ -1,104 +0,0 @@ -import { ECRClient, BatchGetImageCommand } from '@aws-sdk/client-ecr'; -import fs from 'fs'; -import ora from 'ora'; -import Handlebars from 'handlebars'; - -const retries = {}; -const MAX_RETRIES = 60; - -/** - * Ensure docker artifacts are present before deploy - * - * @param {Credentials} creds Credentials - */ -export default async function check(creds) { - const images = []; - - // docker check explicitly disabled - if ( - creds.dotdeploy.artifacts - && creds.dotdeploy.artifacts.docker === false - ) { - return; - } else if ( - !creds.dotdeploy.artifacts - || !creds.dotdeploy.artifacts.docker - ) { - // No dotdeploy or docker file found - try { - fs.accessSync('./Dockerfile'); - } catch (err) { - return; - } - - images.push('{{project}}:{{gitsha}}'); - } else if ( - creds.dotdeploy.artifacts - && creds.dotdeploy.artifacts.docker - ) { - if (typeof creds.dotdeploy.artifacts.docker === 'string') { - images.push(creds.dotdeploy.artifacts.docker); - } else { - creds.dotdeploy.artifacts.docker.forEach((image) => { - images.push(image); - }); - } - } - - for (const image of images) { - await single(creds, image); - } - - return true; -} - -function single(creds, image) { - return new Promise((resolve, reject) => { - const ecr = new ECRClient({ - credentials: creds.aws, - region: creds.region - }); - - image = Handlebars.compile(image)({ - rootStackName: `${creds.repo}-${creds.stack}`, - fullStackName: `${creds.repo}-${creds.name}`, - accountId: creds._accountId, - stack: creds.stack, - region: creds.region, - project: creds.repo, - gitsha: creds.sha - }); - - const progress = ora(`Docker Image: AWS::ECR:${image}`).start(); - - retries[image] = 0; - - if (image.split(':').length !== 2) { - return reject(new Error('docker artifact must be in format :')); - } - - checkecr(); - - async function checkecr() { - try { - const data = await ecr.send(new BatchGetImageCommand({ - imageIds: [{ imageTag: image.split(':')[1] }], - repositoryName: image.split(':')[0] - })); - - if (data && data.images.length) { - progress.succeed(); - return resolve(image); - } else if (retries[image] < MAX_RETRIES) { - retries[image] += 1; - setTimeout(checkecr, 5000); - } else { - progress.fail(); - return reject(new Error(`No image found for: ${image}`)); - } - } catch (err) { - return reject(err); - } - } - }); -} diff --git a/lib/artifacts/docker.ts b/lib/artifacts/docker.ts new file mode 100644 index 0000000..dc67e4c --- /dev/null +++ b/lib/artifacts/docker.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import Handlebars from 'handlebars'; +import ora from 'ora'; +import { BatchGetImageCommand, ECRClient } from '@aws-sdk/client-ecr'; +import type { DeployContext } from '../types.js'; + +const retries = new Map(); +const MAX_RETRIES = 60; + +export default async function check(creds: DeployContext): Promise { + const images: string[] = []; + const dockerArtifacts = creds.dotdeploy.artifacts?.docker; + + if (dockerArtifacts === false) { + return; + } + + if (!dockerArtifacts) { + try { + fs.accessSync('./Dockerfile'); + } catch { + return; + } + + images.push('{{project}}:{{gitsha}}'); + } else if (typeof dockerArtifacts === 'string') { + images.push(dockerArtifacts); + } else { + images.push(...dockerArtifacts); + } + + for (const image of images) { + await single(creds, image); + } + + return true; +} + +async function single(creds: DeployContext, imageTemplate: string): Promise { + const ecr = new ECRClient({ + credentials: creds.aws, + region: creds.region + }); + + const image = Handlebars.compile(imageTemplate)({ + rootStackName: `${creds.repo}-${creds.stack}`, + fullStackName: `${creds.repo}-${creds.name}`, + accountId: creds._accountId ?? await creds.accountId(), + stack: creds.stack, + region: creds.region, + project: creds.repo, + gitsha: creds.sha + }) as string; + + const progress = ora(`Docker Image: AWS::ECR:${image}`).start(); + retries.set(image, 0); + + if (image.split(':').length !== 2) { + progress.fail(); + throw new Error('docker artifact must be in format :'); + } + + return await new Promise((resolve, reject) => { + const checkEcr = async (): Promise => { + try { + const [repositoryName, imageTag] = image.split(':'); + const data = await ecr.send(new BatchGetImageCommand({ + imageIds: [{ imageTag }], + repositoryName + })); + + if ((data.images?.length ?? 0) > 0) { + progress.succeed(); + resolve(image); + return; + } + + const attempt = retries.get(image) ?? 0; + if (attempt < MAX_RETRIES) { + retries.set(image, attempt + 1); + setTimeout(() => { + void checkEcr(); + }, 5000); + return; + } + + progress.fail(); + reject(new Error(`No image found for: ${image}`)); + } catch (error) { + progress.fail(); + reject(error); + } + }; + + void checkEcr(); + }); +} diff --git a/lib/artifacts/s3.js b/lib/artifacts/s3.js deleted file mode 100644 index d39705c..0000000 --- a/lib/artifacts/s3.js +++ /dev/null @@ -1,88 +0,0 @@ -import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3'; -import Handlebars from 'handlebars'; -import ora from 'ora'; - -const retries = {}; -const MAX_RETRIES = 60; - -/** - * Ensure lambda artifacts are present before deploy - * - * @param {Credentials} creds Credentials - */ -export default async function check(creds) { - const objects = []; - - if ( - creds.dotdeploy.artifacts - && creds.dotdeploy.artifacts.s3 === false - ) { - return; - } else if ( - creds.dotdeploy.artifacts - && creds.dotdeploy.artifacts.s3 - ) { - if (typeof creds.dotdeploy.artifacts.s3 === 'string') { - objects.push(creds.dotdeploy.artifacts.s3); - } else { - creds.dotdeploy.artifacts.s3.forEach((l) => { - objects.push(l); - }); - } - } - - for (const object of objects) { - await single(creds, object); - } - - return true; -} - -function single(creds, object) { - return new Promise((resolve, reject) => { - const s3 = new S3Client({ - credentials: creds.aws, - region: creds.region - }); - - object = Handlebars.compile(object)({ - rootStackName: `${creds.repo}-${creds.stack}`, - fullStackName: `${creds.repo}-${creds.name}`, - accountId: creds._accountId, - stack: creds.stack, - region: creds.region, - project: creds.repo, - gitsha: creds.sha - }); - - const progress = ora(`S3 Object: AWS::S3:${object}`).start(); - - retries[object] = 0; - - checks3(); - - async function checks3() { - try { - const data = await s3.send(new HeadObjectCommand({ - Bucket: object.split('/')[0], - Key: object.split('/').splice(1).join('/') - })); - - if (data && data.ContentLength) { - progress.succeed(); - return resolve(object); - } else { - progress.fail(); - return reject(new Error(`No object found for: ${object}`)); - } - } catch (err) { - if (err && String(err).includes('NotFound') && retries[object] < MAX_RETRIES) { - retries[object] += 1; - setTimeout(checks3, 5000); - } else { - return reject(err); - } - } - } - }); -} diff --git a/lib/artifacts/s3.ts b/lib/artifacts/s3.ts new file mode 100644 index 0000000..dc4b5ce --- /dev/null +++ b/lib/artifacts/s3.ts @@ -0,0 +1,83 @@ +import Handlebars from 'handlebars'; +import ora from 'ora'; +import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import type { DeployContext } from '../types.js'; + +const retries = new Map(); +const MAX_RETRIES = 60; + +export default async function check(creds: DeployContext): Promise { + const objects: string[] = []; + const s3Artifacts = creds.dotdeploy.artifacts?.s3; + + if (s3Artifacts === false || !s3Artifacts) { + return; + } + + if (typeof s3Artifacts === 'string') { + objects.push(s3Artifacts); + } else { + objects.push(...s3Artifacts); + } + + for (const object of objects) { + await single(creds, object); + } + + return true; +} + +async function single(creds: DeployContext, objectTemplate: string): Promise { + const s3 = new S3Client({ + credentials: creds.aws, + region: creds.region + }); + + const object = Handlebars.compile(objectTemplate)({ + rootStackName: `${creds.repo}-${creds.stack}`, + fullStackName: `${creds.repo}-${creds.name}`, + accountId: creds._accountId ?? await creds.accountId(), + stack: creds.stack, + region: creds.region, + project: creds.repo, + gitsha: creds.sha + }) as string; + + const progress = ora(`S3 Object: AWS::S3:${object}`).start(); + retries.set(object, 0); + + return await new Promise((resolve, reject) => { + const checkS3 = async (): Promise => { + try { + const [bucket, ...keyParts] = object.split('/'); + const data = await s3.send(new HeadObjectCommand({ + Bucket: bucket, + Key: keyParts.join('/') + })); + + if ((data.ContentLength ?? 0) > 0) { + progress.succeed(); + resolve(object); + return; + } + + progress.fail(); + reject(new Error(`No object found for: ${object}`)); + } catch (error) { + const attempt = retries.get(object) ?? 0; + if (String(error).includes('NotFound') && attempt < MAX_RETRIES) { + retries.set(object, attempt + 1); + setTimeout(() => { + void checkS3(); + }, 5000); + return; + } + + progress.fail(); + reject(error); + } + }; + + void checkS3(); + }); +} diff --git a/lib/cancel.js b/lib/cancel.ts similarity index 72% rename from lib/cancel.js rename to lib/cancel.ts index dc63134..c4eb9bb 100644 --- a/lib/cancel.js +++ b/lib/cancel.ts @@ -1,15 +1,9 @@ -/** - * @class - */ export default class Cancel { static short = 'Cancel a stack update, rolling it back'; - /** - * Print help documentation to the screen - */ - static help() { + static help(): void { console.log(); - console.log('Cancel a deploy to an stack, rolling it back'); + console.log('Cancel a deploy to a stack, rolling it back'); console.log(); console.log('Usage: deploy cancel [--help]'); console.log(); diff --git a/lib/commands.js b/lib/commands.ts similarity index 71% rename from lib/commands.js rename to lib/commands.ts index 3cebc64..eab7644 100644 --- a/lib/commands.js +++ b/lib/commands.ts @@ -1,15 +1,16 @@ -import env from './env.js'; -import list from './list.js'; import cancel from './cancel.js'; -import init from './init.js'; -import info from './info.js'; -import json from './json.js'; import create from './create.js'; import del from './delete.js'; -import update from './json.js'; +import env from './env.js'; import exec from './exec.js'; +import info from './info.js'; +import init from './init.js'; +import json from './json.js'; +import list from './list.js'; +import update from './update.js'; +import type { CommandModule } from './types.js'; -export default { +const commands: Record = { delete: del, create, update, @@ -21,3 +22,5 @@ export default { cancel, exec }; + +export default commands; diff --git a/lib/context.js b/lib/context.js deleted file mode 100644 index dfc5ca8..0000000 --- a/lib/context.js +++ /dev/null @@ -1,251 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import Git from './git.js'; -import AJV from 'ajv'; -import CFN from '@openaddresses/cfn-config'; -import { fromIni } from '@aws-sdk/credential-providers'; -import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; - -const ajv = new AJV({ - allErrors: true -}); - -// Only these keys are allowed to overwrite Credentials keys -// when loaded from a .deployrc.json file -const PROFILE_KEYS = ['region', 'github']; - -/** - * Store all credentials required for deploy functionality - * - * @class - * - * @param {Object} argv Command Line Arguments - * - * @prop {string} repo Git Repository Name - * @prop {string} sha Current Git Commit Sha - * @prop {string} name Git Repository Name Override - * @prop {string} stack - * @prop {string} subname - * @prop {string} github Github API token if provided - * @prop {string} user Name of the current git user - * @prop {string} user Name of the github repo owner - * @prop {string} origin Name of the origin repo - * @prop {string} template path to cloudformation template - * @prop {string} profile - * @prop {Object} dotdeploy - * @prop {Object} profiles - * @prop {Array} tags - * @prop {string} region - * @prop {object} aws AWS Credentials - */ -export default class Credentials { - static async generate(argv) { - const creds = new Credentials(); - creds.user = Git.user(); - creds.owner = Git.owner(); - - creds.repo = Git.repo(); - creds.sha = Git.sha(); - - if (!creds.repo) throw new Error('No Git Repo detected! Are you in the correct directory? Or did you download a static non-git copy of the repo?'); - if (!creds.sha) throw new Error('Could not determine git sha'); - - // If GH deployment has been requested, store ID here - creds.deployment = false; - - creds.name = false; - creds.stack = (argv._[3] || '').replace(new RegExp(`^${this.repo}-`), ''); - creds.subname = false; - creds.template = false; - creds.profile = false; - creds.dotdeploy = {}; - creds.profiles = {}; - creds.tags = []; - - creds.region = 'us-east-1'; - - creds.github = false; - creds.force = argv.force || false; - - creds.dotdeploy = Credentials.dot_deploy(); - - let readcreds; - try { - readcreds = JSON.parse(fs.readFileSync(path.resolve(process.env.HOME, '.deployrc.json'))); - } catch (err) { - readcreds = {}; - } - - const validate = ajv.compile(JSON.parse(fs.readFileSync(new URL('../data/rc_schema.json', import.meta.url)))); - - if (!validate(readcreds)) { - console.error(JSON.stringify(validate.errors, null, 4)); - throw new Error('~/.deployrc.json does not conform to schema'); - } - - Object.keys(readcreds).forEach((key) => { - if (readcreds[key] !== undefined) { - creds.profiles[key] = readcreds[key]; - } - }); - - if (argv.template) { - creds.subname = path.parse(argv.template).name.replace(/\.template/, '') + '-'; - creds.template = argv.template; - } else if (argv.template === false) { - creds.subname = null; - creds.template = null; - } else { - creds.subname = ''; - - const cf_base = `${creds.repo}.template`; - let cf_path = false; - for (const file of fs.readdirSync(path.resolve('./cloudformation/'))) { - if (file.indexOf(cf_base) === -1) continue; - - if ( - path.parse(file).name === creds.repo + '.template' - && ( - path.parse(file).ext === '.js' - || path.parse(file).ext === '.json' - ) - ) { - cf_path = path.resolve('./cloudformation/', file); - break; - } - } - - if (!cf_path) { - throw new Error(`Could not find CF Template in cloudformation/${creds.repo}.template.js(on)`); - } - - creds.template = cf_path; - } - - if (creds.dotdeploy.name) { - creds.repo = creds.dotdeploy.name; - } - - if (argv.profile) { - creds.profile = argv.profile; - } else if (creds.dotdeploy.profile) { - creds.profile = creds.dotdeploy.profile; - } else if (Object.keys(creds.profiles).length > 1) { - throw new Error('Multiple deploy profiles found. Deploy with --profile or set a .deploy file'); - } else { - creds.profile = Object.keys(creds.profiles)[0]; - } - - if (!creds.profiles[creds.profile]) creds.profiles[creds.profile] = {}; - - PROFILE_KEYS.forEach((key) => { - if (creds.profiles[creds.profile][key] !== undefined) { - creds[key] = creds.profiles[creds.profile][key]; - } - }); - - // CLI Params override config - if (argv.region) { - creds.region = argv.region; - } else if (creds.dotdeploy.region) { - creds.region = creds.dotdeploy.region; - } - - if (argv.name) { - creds.name = argv.name.replace(new RegExp(`^${creds.repo}-`, '')); - } else { - creds.name = (creds.subname || '') + creds.stack; - } - - if (!readcreds[creds.profile]) { - readcreds[creds.profile] = {}; - } - - if (readcreds[creds.profile].tags) { - creds.tags = creds.tags.concat(readcreds[creds.profile].tags); - } - - if (creds.dotdeploy.tags) { - creds.tags = creds.tags.concat(creds.dotdeploy.tags || []); - } - - // Load GitHub polling configuration - creds.githubPolling = { - timeout: 30 * 60 * 1000, // 30 minutes default - interval: 30 * 1000 // 30 seconds default - }; - - creds.aws = {}; - - try { - creds.aws = await (await fromIni({ - profile: creds.profile - })()); - } catch (err) { - throw new Error('creds not set: run deploy init'); - } - - creds.cfn = new CFN({ - region: creds.region, - credentials: creds.aws - },{ - tags: creds.tags, - name: creds.repo, - configBucket: `cfn-config-active-${await creds.accountId()}-${creds.region}`, - templateBucket: `cfn-config-templates-${await creds.accountId()}-${creds.region}` - }); - - - return creds; - } - - /** - * Attempt to read a dot deploy file - * - * @returns {Object} Dot Deploy Object - */ - static dot_deploy() { - const attempts = [ - path.resolve('./.deploy'), - path.resolve(Git.root(), '.deploy') - ]; - - let dotdeploy = false; - - - const validate = ajv.compile(JSON.parse(fs.readFileSync(new URL('../data/schema.json', import.meta.url)))); - - for (const attempt of attempts) { - try { - dotdeploy = JSON.parse(fs.readFileSync(attempt)); - - if (!validate(dotdeploy)) { - console.error(JSON.stringify(validate.errors, null, 4)); - throw new Error(`${attempt} does not conform to schema`); - } - } catch (err) { - if (err.name === 'SyntaxError') { - throw new Error(`Invalid JSON in ${attempt} file`); - } - - continue; - } - } - - return dotdeploy; - } - - async accountId() { - if (this._accountId) return this._accountId; - - const sts = new STSClient({ - credentials: this.aws, - region: this.region - }); - - const account = await sts.send(new GetCallerIdentityCommand()); - this._accountId = account.Account; - - return this._accountId; - } -} diff --git a/lib/context.ts b/lib/context.ts new file mode 100644 index 0000000..49df6ee --- /dev/null +++ b/lib/context.ts @@ -0,0 +1,242 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import AjvModule from 'ajv'; +import CFN from '@openaddresses/cfn-config'; +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; +import { fromIni } from '@aws-sdk/credential-providers'; +import Git from './git.js'; +import type { AwsCredentials, DeployArgv, ConfigTag, DeployProfile, DotDeployConfig, GitHubPollingConfig } from './types.js'; + +const AjvConstructor = ((AjvModule as unknown as { default?: unknown }).default ?? AjvModule) as new (options?: object) => { + compile: (schema: object) => { + (data: unknown): boolean; + errors?: unknown; + }; +}; + +const ajv = new AjvConstructor({ + allErrors: true +}); + +const PROFILE_KEYS = ['region', 'github'] as const; + +export default class Credentials { + user = ''; + owner: string | false = false; + repo = ''; + sha = ''; + deployment: number | false = false; + name = ''; + stack = ''; + subname: string | null = ''; + template: string | null = null; + profile = ''; + dotdeploy: DotDeployConfig = {}; + profiles: Record = {}; + tags: ConfigTag[] = []; + region = 'us-east-1'; + github: string | false = false; + githubPolling: GitHubPollingConfig = { + timeout: 30 * 60 * 1000, + interval: 30 * 1000 + }; + force = false; + aws!: AwsCredentials; + cfn!: CFN; + _accountId?: string; + + static async generate(argv: DeployArgv): Promise { + const creds = new Credentials(); + creds.user = Git.user(); + creds.owner = Git.owner(); + creds.repo = Git.repo(); + creds.sha = Git.sha(); + + if (!creds.repo) { + throw new Error('No Git Repo detected! Are you in the correct directory? Or did you download a static non-git copy of the repo?'); + } + + if (!creds.sha) { + throw new Error('Could not determine git sha'); + } + + creds.stack = String(argv._[3] ?? '').replace(new RegExp(`^${creds.repo}-`), ''); + creds.force = argv.force ?? false; + creds.dotdeploy = Credentials.dot_deploy() || {}; + + const readcreds = Credentials.readProfiles(); + + Object.keys(readcreds).forEach((key) => { + if (readcreds[key] !== undefined) { + creds.profiles[key] = readcreds[key] as DeployProfile; + } + }); + + if (typeof argv.template === 'string') { + creds.subname = `${path.parse(argv.template).name.replace(/\.template/, '')}-`; + creds.template = argv.template; + } else if (argv.template === false) { + creds.subname = null; + creds.template = null; + } else { + creds.subname = ''; + creds.template = Credentials.findTemplatePath(creds.repo); + } + + if (creds.dotdeploy.name) { + creds.repo = creds.dotdeploy.name; + } + + if (argv.profile) { + creds.profile = argv.profile; + } else if (creds.dotdeploy.profile) { + creds.profile = creds.dotdeploy.profile; + } else if (Object.keys(creds.profiles).length > 1) { + throw new Error('Multiple deploy profiles found. Deploy with --profile or set a .deploy file'); + } else { + creds.profile = Object.keys(creds.profiles)[0] ?? 'default'; + } + + if (!creds.profiles[creds.profile]) { + creds.profiles[creds.profile] = {}; + } + + PROFILE_KEYS.forEach((key) => { + const value = creds.profiles[creds.profile][key]; + if (value !== undefined) { + creds[key] = value; + } + }); + + if (argv.region) { + creds.region = argv.region; + } else if (creds.dotdeploy.region) { + creds.region = creds.dotdeploy.region; + } + + if (argv.name) { + creds.name = argv.name.replace(new RegExp(`^${creds.repo}-`), ''); + } else { + creds.name = `${creds.subname ?? ''}${creds.stack}`; + } + + const profileConfig = readcreds[creds.profile] ?? {}; + if (profileConfig.tags) { + creds.tags = creds.tags.concat(profileConfig.tags); + } + + if (creds.dotdeploy.tags) { + creds.tags = creds.tags.concat(creds.dotdeploy.tags); + } + + try { + creds.aws = await (await fromIni({ + profile: creds.profile + })()) as Credentials['aws']; + } catch { + throw new Error('creds not set: run deploy init'); + } + + const accountId = await creds.accountId(); + creds.cfn = new CFN({ + region: creds.region, + credentials: creds.aws + }, { + tags: creds.tags, + name: creds.repo, + configBucket: `cfn-config-active-${accountId}-${creds.region}`, + templateBucket: `cfn-config-templates-${accountId}-${creds.region}` + }); + + return creds; + } + + static dot_deploy(): DotDeployConfig | false { + const attempts = Array.from(new Set([ + path.resolve('./.deploy'), + path.resolve(Git.root(), '.deploy') + ])); + + const validate = ajv.compile(JSON.parse(fs.readFileSync(new URL('../data/schema.json', import.meta.url), 'utf8')) as object); + + for (const attempt of attempts) { + if (!fs.existsSync(attempt)) { + continue; + } + + let dotdeploy: DotDeployConfig; + try { + dotdeploy = JSON.parse(fs.readFileSync(attempt, 'utf8')) as DotDeployConfig; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in ${attempt} file`, { cause: error }); + } + + throw error; + } + + if (!validate(dotdeploy)) { + console.error(JSON.stringify(validate.errors, null, 4)); + throw new Error(`${attempt} does not conform to schema`); + } + + return dotdeploy; + } + + return false; + } + + async accountId(): Promise { + if (this._accountId) { + return this._accountId; + } + + const sts = new STSClient({ + credentials: this.aws, + region: this.region + }); + + const account = await sts.send(new GetCallerIdentityCommand({})); + if (!account.Account) { + throw new Error('Unable to determine AWS account ID'); + } + + this._accountId = account.Account; + return this._accountId; + } + + private static readProfiles(): Record { + let readcreds: Record; + try { + const raw = fs.readFileSync(path.resolve(process.env.HOME ?? '', '.deployrc.json'), 'utf8'); + readcreds = JSON.parse(raw) as Record; + } catch { + readcreds = {}; + } + + const validate = ajv.compile(JSON.parse(fs.readFileSync(new URL('../data/rc_schema.json', import.meta.url), 'utf8')) as object); + if (!validate(readcreds)) { + console.error(JSON.stringify(validate.errors, null, 4)); + throw new Error('~/.deployrc.json does not conform to schema'); + } + + return readcreds; + } + + private static findTemplatePath(repo: string): string { + const baseName = `${repo}.template`; + const cloudformationDir = path.resolve('./cloudformation/'); + for (const file of fs.readdirSync(cloudformationDir)) { + if (!file.includes(baseName)) { + continue; + } + + const parsed = path.parse(file); + if (parsed.name === baseName && (parsed.ext === '.js' || parsed.ext === '.json')) { + return path.resolve(cloudformationDir, file); + } + } + + throw new Error(`Could not find CF Template in cloudformation/${repo}.template.js(on)`); + } +} diff --git a/lib/create.js b/lib/create.ts similarity index 83% rename from lib/create.js rename to lib/create.ts index 1bd5b80..401f287 100644 --- a/lib/create.js +++ b/lib/create.ts @@ -1,13 +1,7 @@ -/** - * @class - */ export default class Create { static short = 'Create a new stack of the current repo'; - /** - * Print help documentation to the screen - */ - static help() { + static help(): void { console.log(); console.log('Create a new CloudFormation stack'); console.log(); diff --git a/lib/delete.js b/lib/delete.ts similarity index 83% rename from lib/delete.js rename to lib/delete.ts index 0fe7a69..18f3d4b 100644 --- a/lib/delete.js +++ b/lib/delete.ts @@ -1,13 +1,7 @@ -/** - * @class - */ export default class Delete { static short = 'Delete an existing stack of the current repo'; - /** - * Print help documentation to the screen - */ - static help() { + static help(): void { console.log(); console.log('Delete a CloudFormation stack'); console.log(); diff --git a/lib/env.js b/lib/env.ts similarity index 65% rename from lib/env.js rename to lib/env.ts index 4603611..a0fd1e3 100644 --- a/lib/env.js +++ b/lib/env.ts @@ -1,13 +1,9 @@ -/** - * @class - */ +import type { DeployContext } from './types.js'; + export default class Env { static short = 'Setup AWS env vars in current shell'; - /** - * Print help documentation to the screen - */ - static help() { + static help(): void { console.log(); console.log('Usage: deploy env'); console.log(); @@ -15,17 +11,15 @@ export default class Env { console.log(); } - /** - * Export environment variables into the shell - * - * @param {Context} context Context - */ - static async main(context) { + static async main(context: DeployContext): Promise { console.log(`export AWS_ACCOUNT_ID=${await context.accountId()}`); console.log(`export AWS_REGION=${context.region}`); console.log(`export AWS_ACCESS_KEY_ID=${context.aws.accessKeyId}`); console.log(`export AWS_SECRET_ACCESS_KEY=${context.aws.secretAccessKey}`); - if (context.aws.sessionToken) console.log(`export AWS_SESSION_TOKEN=${context.aws.sessionToken}`); + + if (context.aws.sessionToken) { + console.log(`export AWS_SESSION_TOKEN=${context.aws.sessionToken}`); + } console.error(`ok - [${context.profile}] environment configured`); } diff --git a/lib/exec.js b/lib/exec.js deleted file mode 100644 index 33d9c8d..0000000 --- a/lib/exec.js +++ /dev/null @@ -1,135 +0,0 @@ -import inquirer from 'inquirer'; -import { - ECSClient, - ListClustersCommand, - ListServicesCommand, - DescribeServicesCommand, - DescribeTasksCommand, - ListTasksCommand -} from '@aws-sdk/client-ecs'; -import minimist from 'minimist'; - -/** - * @class - */ -export default class Exec { - static short = 'SSH into a fargate container'; - - /** - * Print help documentation to the screen - */ - static help() { - console.log(); - console.log('Usage: deploy exec'); - console.log(); - console.log('Run a command on a FARGATE service'); - console.log(); - console.log('[options]:'); - console.log(' --region Override default region to perform operations in'); - console.log(' --cluster Set cluster to perform operation in'); - console.log(' --task Set TaskId to perform operation in'); - console.log(' --command Set command to run - defaults to /bin/bash'); - console.log(); - } - - /** - * List current stacks deployed to a given profile - * - * @param {Context} context - * @param {Object} argv - */ - static async main(context, argv) { - argv = minimist(argv, { - string: ['region', 'cluster', 'task', 'command'] - }); - - if (!argv) { - argv.region = context.region; - } - - const ecs = new ECSClient({ - credentials: context.aws, - region: context.region - }); - - if (!argv.cluster) { - const res = await ecs.send(new ListClustersCommand({})); - - Object.assign(argv, await inquirer.prompt({ - type: 'list', - name: 'cluster', - message: 'ECS Cluster', - choices: res.clusterArns.map((cluster) => { - return cluster.split('/').pop(); - }).sort() - })); - } - - if (!argv.task) { - const res = await ecs.send(new ListServicesCommand({ - cluster: argv.cluster - })); - - Object.assign(argv, await inquirer.prompt({ - type: 'list', - name: 'service', - message: 'ECS Service', - choices: res.serviceArns.map((service) => { - return service.split('/').pop(); - }).sort() - })); - - const service = await ecs.send(new DescribeServicesCommand({ - cluster: argv.cluster, - services: [argv.service] - })); - - if (!service.services[0].enableExecuteCommand) { - throw new Error('Service does not have enableExecuteCommand set to true - exec is disabled'); - } - - const tasks = await ecs.send(new ListTasksCommand({ - cluster: argv.cluster, - serviceName: argv.service - })); - - Object.assign(argv, await inquirer.prompt({ - type: 'list', - name: 'task', - message: 'ECS TASK', - choices: tasks.taskArns.map((task) => { - return task.split('/').pop(); - }).sort() - })); - } - - if (!argv.container) { - const tasks = await ecs.send(new DescribeTasksCommand({ - cluster: argv.cluster, - tasks: [argv.task] - })); - - Object.assign(argv, await inquirer.prompt({ - type: 'list', - name: 'container', - message: 'ECS Container', - choices: tasks.tasks[0].containers.map((container) => { - return container.name; - }).sort() - })); - } - - console.log(`aws ecs execute-command --cluster ${argv.cluster} --task ${argv.task} --container ${argv.container} --command ${argv.command || '/bin/bash'} --interactive`); - - /** TODO Eventually support executing directly - const exec = await ecs.send(new ExecuteCommandCommand({ - interactive: true, - cluster: argv.cluster, - task: argv.task, - container: argv.container, - command: argv.command || '/bin/bash' - })); - - */ - } -} diff --git a/lib/exec.ts b/lib/exec.ts new file mode 100644 index 0000000..bbdce7c --- /dev/null +++ b/lib/exec.ts @@ -0,0 +1,130 @@ +import inquirer from 'inquirer'; +import minimist from 'minimist'; +import { + DescribeServicesCommand, + DescribeTasksCommand, + ECSClient, + ListClustersCommand, + ListServicesCommand, + ListTasksCommand +} from '@aws-sdk/client-ecs'; +import type { DeployArgv, DeployContext } from './types.js'; + +export default class Exec { + static short = 'SSH into a fargate container'; + + static help(): void { + console.log(); + console.log('Usage: deploy exec'); + console.log(); + console.log('Run a command on a FARGATE service'); + console.log(); + console.log('[options]:'); + console.log(' --region Override default region to perform operations in'); + console.log(' --cluster Set cluster to perform operation in'); + console.log(' --task Set TaskId to perform operation in'); + console.log(' --command Set command to run - defaults to /bin/bash'); + console.log(); + } + + static async main(context: DeployContext, argvInput: string[]): Promise { + const argv = minimist(argvInput, { + string: ['region', 'cluster', 'task', 'command', 'container'] + }) as DeployArgv; + + if (!argv.region) { + argv.region = context.region; + } + + const ecs = new ECSClient({ + credentials: context.aws, + region: argv.region + }); + + if (!argv.cluster) { + const response = await ecs.send(new ListClustersCommand({})); + const choices = (response.clusterArns ?? []).map((cluster) => cluster.split('/').pop() ?? cluster).sort(); + + if (choices.length === 0) { + throw new Error('No ECS clusters found'); + } + + const answer = await inquirer.prompt<{ cluster: string }>({ + type: 'list', + name: 'cluster', + message: 'ECS Cluster', + choices + }); + argv.cluster = answer.cluster; + } + + if (!argv.task) { + const servicesResponse = await ecs.send(new ListServicesCommand({ + cluster: argv.cluster + })); + const serviceChoices = (servicesResponse.serviceArns ?? []).map((service) => service.split('/').pop() ?? service).sort(); + + if (serviceChoices.length === 0) { + throw new Error(`No ECS services found for cluster ${argv.cluster}`); + } + + const serviceAnswer = await inquirer.prompt<{ service: string }>({ + type: 'list', + name: 'service', + message: 'ECS Service', + choices: serviceChoices + }); + argv.service = serviceAnswer.service; + + const service = await ecs.send(new DescribeServicesCommand({ + cluster: argv.cluster, + services: [argv.service] + })); + const selectedService = service.services?.[0]; + + if (!selectedService?.enableExecuteCommand) { + throw new Error('Service does not have enableExecuteCommand set to true - exec is disabled'); + } + + const tasks = await ecs.send(new ListTasksCommand({ + cluster: argv.cluster, + serviceName: argv.service + })); + const taskChoices = (tasks.taskArns ?? []).map((task) => task.split('/').pop() ?? task).sort(); + + if (taskChoices.length === 0) { + throw new Error(`No ECS tasks found for service ${argv.service}`); + } + + const taskAnswer = await inquirer.prompt<{ task: string }>({ + type: 'list', + name: 'task', + message: 'ECS Task', + choices: taskChoices + }); + argv.task = taskAnswer.task; + } + + if (!argv.container) { + const tasks = await ecs.send(new DescribeTasksCommand({ + cluster: argv.cluster, + tasks: [argv.task ?? ''] + })); + const containerChoices = (tasks.tasks?.[0]?.containers ?? []).map((container) => container.name ?? '').filter(Boolean).sort(); + + if (containerChoices.length === 0) { + throw new Error(`No containers found for task ${argv.task}`); + } + + const containerAnswer = await inquirer.prompt<{ container: string }>({ + type: 'list', + name: 'container', + message: 'ECS Container', + choices: containerChoices + }); + argv.container = containerAnswer.container; + } + + console.log(`aws ecs execute-command --cluster ${argv.cluster} --task ${argv.task} --container ${argv.container} --command ${argv.command || '/bin/bash'} --interactive`); + } +} diff --git a/lib/gh.js b/lib/gh.js deleted file mode 100644 index 704e59e..0000000 --- a/lib/gh.js +++ /dev/null @@ -1,249 +0,0 @@ -import fs from 'node:fs'; -import ora from 'ora'; -import Git from './git.js'; - -/** - * @class - */ -export default class GH { - /** - * Create a new github API object - * - * @constructor - * @param {Context} context Context object - */ - constructor(context) { - this.url = 'https://api.github.com'; - const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))); - - this.repo = Git.repo(); - - this.enabled = context.github; - - this.headers = { - Accept: 'application/vnd.github.v3+json', - Authorization: `Bearer ${context.github}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': `openaddresses-deploy@${pkg.version}`, - 'Content-Type': 'application/json' - }; - - this.context = context; - } - - /** - * Create or update a deploy status on github.com - * - * @param {string} stack The stackname to update - * @param {boolean} success Was the deployment successful - */ - async deployment(stack, success) { - if (!this.enabled) return; - - if (success === undefined) { - success = 'pending'; - } else if (success) { - success = 'success'; - } else if (!success) { - success = 'failed'; - } - - // Poll GitHub status checks before proceeding with deployment - if (this.context.force !== true) { - try { - await this.pollStatusChecks(this.context.githubPolling); - } catch (err) { - console.error(`Status Check Polling Failed: ${err.message}`); - process.exit(1); - } - } - - if (this.context.deployment) { - return await this.deployment_update(stack, success); - } else { - const deploy_id = await this.deployment_list(stack); - - if (!deploy_id) { - await this.deployment_create(stack); - } else { - this.context.deployment = deploy_id; - - await this.deployment_update(stack, success); - } - } - } - - async status() { - const res = await fetch(this.url + `/repos/${this.context.owner}/${this.repo}/commits/${this.context.sha}/check-runs`, { - method: 'GET', - headers: this.headers - }); - - const body = await res.json(); - - if (!res.ok) { - if (this.context.force) { - console.log('warn - Error in Github Status, skipping due to --force'); - } else { - console.error(body); - throw new Error('Could not list status checks'); - } - } - - return body; - } - - /** - * Poll GitHub status checks until they pass or fail - * - * @param {Object} options - Polling options - * @param {number} options.timeout - Timeout in milliseconds (default: 30 minutes) - * @param {number} options.interval - Poll interval in milliseconds (default: 30 seconds) - * @returns {Promise} - True if checks pass, throws error if they fail - */ - async pollStatusChecks(options = {}) { - const timeout = options.timeout || 30 * 60 * 1000; // 30 minutes default - const interval = options.interval || 30 * 1000; // 30 seconds default - const startTime = Date.now(); - - const progress = ora(`GitHub Status Checks: ${this.context.sha}`).start(); - - try { - while (Date.now() - startTime < timeout) { - try { - const status = await this.status(); - - progress.text = `GitHub Status Checks: (${status.check_runs?.length || 0} checks)`; - - const completed = status.check_runs?.filter((s) => s.status === 'completed') || []; - - if (completed.length === status.check_runs?.length) { - progress.succeed(); - return; - }; - - await new Promise((resolve) => setTimeout(resolve, interval)); - } catch (error) { - if (error.message.includes('Status checks failed') || error.message.includes('encountered errors')) { - throw error; // Re-throw status check failures - } - - progress.text = `GitHub Status Checks: Error - ${error.message}`; - await new Promise((resolve) => setTimeout(resolve, interval)); - } - } - - progress.fail(`GitHub Status Checks: Timeout after ${timeout / 1000 / 60} minutes`); - throw new Error(`❌ Timeout waiting for status checks to complete after ${timeout / 1000 / 60} minutes`); - } catch (error) { - // Ensure we clean up the spinner if it's still running - if (progress.isSpinning) { - progress.fail('GitHub Status Checks: Failed'); - } - throw error; - } - } - - async deployment_list(stack) { - const url = new URL(this.url + `/repos/${this.context.owner}/${this.repo}/deployments`); - url.searchParams.append('sha', this.context.sha); - url.searchParams.append('task', 'deploy'); - url.searchParams.append('environment', stack); - - const res = await fetch(url, { - method: 'GET', - headers: this.headers - }); - - const body = await res.json(); - - if (!res.ok) { - if (this.context.force) { - console.log('warn - Error in Github Deployment List, skipping due to --force'); - } else { - console.error(body); - throw new Error('Could not list deployments'); - } - } else { - if (body.length > 0) return body[0].id; - return false; - } - } - - async deployment_create(stack) { - const res = await fetch(this.url + `/repos/${this.context.owner}/${this.repo}/deployments`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - ref: this.context.sha, - task: 'deploy', - environment: stack, - production_environment: ['prod', 'production'].includes(stack) - }) - }); - - const body = await res.json(); - - if (!res.ok) { - if (this.context.force) { - console.log('warn - Error in Github Deployment Creation, skipping due to --force'); - } else { - console.error(body); - throw new Error('Could not create deployment'); - } - } else { - this.context.deployment = body.id; - return true; - } - } - - /** - * Create or update a deploy status on github.com - * - * @param {string} stack The stackname to update - */ - async deployment_update(stack, success) { - const res = await fetch(this.url + `/repos/${this.context.owner}/${this.repo}/deployments/${this.context.deployment}/statuses`, { - method: 'POST', - headers: this.headers, - body: JSON.stringify({ - state: success - }) - }); - - const body = await res.json(); - - if (!res.ok) { - if (this.context.force) { - console.log('warn - Error in Github Deployment Update, skipping due to --force'); - } else { - console.error(body); - throw new Error('Could not create deployment'); - } - } else { - return body; - } - } - - /** - * delete a deployment on github.com - */ - async deployment_delete() { - const res = await fetch(this.url + `/repos/${this.context.owner}/${this.repo}/deployments/${this.context.deployment}`, { - method: 'DELETE', - headers: this.headers - }); - - const body = await res.json(); - if (!res.ok) { - if (this.context.force) { - console.log('warn - Error in Github Deployment Deletion skipping due to --force'); - } else { - console.error(body); - throw new Error('Could not delete deployment'); - } - } else { - return body; - } - } -} diff --git a/lib/gh.ts b/lib/gh.ts new file mode 100644 index 0000000..17c66c3 --- /dev/null +++ b/lib/gh.ts @@ -0,0 +1,246 @@ +import fs from 'node:fs'; +import ora from 'ora'; +import Git from './git.js'; +import type { DeployContext, GitHubPollingConfig } from './types.js'; + +type DeploymentState = 'pending' | 'success' | 'failure'; + +interface GitHubCheckRun { + name?: string; + status?: string; + conclusion?: string | null; +} + +interface GitHubCheckRunsResponse { + check_runs?: GitHubCheckRun[]; +} + +interface GitHubDeployment { + id: number; +} + +export default class GH { + readonly url = 'https://api.github.com'; + readonly repo: string; + readonly enabled: boolean; + readonly headers: Record; + readonly context: DeployContext; + + constructor(context: DeployContext) { + const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }; + + this.repo = Git.repo(); + this.enabled = Boolean(context.github); + this.headers = { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${context.github ?? ''}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': `openaddresses-deploy@${pkg.version}`, + 'Content-Type': 'application/json' + }; + this.context = context; + } + + async deployment(stack: string, success?: boolean): Promise { + if (!this.enabled) { + return; + } + + const state = this.deploymentState(success); + + if (state === 'pending' && this.context.force !== true) { + try { + await this.pollStatusChecks(this.context.githubPolling); + } catch (error) { + const err = asError(error); + console.error(`Status Check Polling Failed: ${err.message}`); + process.exit(1); + } + } + + if (this.context.deployment) { + await this.deployment_update(stack, state); + return; + } + + const deploymentId = await this.deployment_list(stack); + if (!deploymentId) { + await this.deployment_create(stack); + if (state !== 'pending') { + await this.deployment_update(stack, state); + } + return; + } + + this.context.deployment = deploymentId; + await this.deployment_update(stack, state); + } + + async status(): Promise { + const response = await fetch(`${this.url}/repos/${this.context.owner}/${this.repo}/commits/${this.context.sha}/check-runs`, { + method: 'GET', + headers: this.headers + }); + + const body = await response.json() as GitHubCheckRunsResponse; + if (!response.ok) { + if (this.context.force) { + console.log('warn - Error in Github Status, skipping due to --force'); + return { check_runs: [] }; + } + + console.error(body); + throw new Error('Could not list status checks'); + } + + return body; + } + + async pollStatusChecks(options: Partial = {}): Promise { + const timeout = options.timeout ?? 30 * 60 * 1000; + const interval = options.interval ?? 30 * 1000; + const startTime = Date.now(); + const progress = ora(`GitHub Status Checks: ${this.context.sha}`).start(); + + try { + while (Date.now() - startTime < timeout) { + const status = await this.status(); + const checks = status.check_runs ?? []; + const completed = checks.filter((check) => check.status === 'completed'); + const failed = completed.filter((check) => { + const conclusion = check.conclusion ?? ''; + return !['success', 'neutral', 'skipped'].includes(conclusion); + }); + + progress.text = `GitHub Status Checks: (${checks.length} checks)`; + + if (failed.length > 0) { + const names = failed.map((check) => check.name ?? 'unknown').join(', '); + throw new Error(`Status checks failed: ${names}`); + } + + if (checks.length > 0 && completed.length === checks.length) { + progress.succeed(); + return; + } + + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + progress.fail(`GitHub Status Checks: Timeout after ${timeout / 1000 / 60} minutes`); + throw new Error(`Timeout waiting for status checks to complete after ${timeout / 1000 / 60} minutes`); + } catch (error) { + if (progress.isSpinning) { + progress.fail('GitHub Status Checks: Failed'); + } + + throw error; + } + } + + async deployment_list(stack: string): Promise { + const url = new URL(`${this.url}/repos/${this.context.owner}/${this.repo}/deployments`); + url.searchParams.append('sha', this.context.sha); + url.searchParams.append('task', 'deploy'); + url.searchParams.append('environment', stack); + + const response = await fetch(url, { + method: 'GET', + headers: this.headers + }); + const body = await response.json() as GitHubDeployment[]; + + if (!response.ok) { + if (this.context.force) { + console.log('warn - Error in Github Deployment List, skipping due to --force'); + return false; + } + + console.error(body); + throw new Error('Could not list deployments'); + } + + return body.length > 0 ? body[0].id : false; + } + + async deployment_create(stack: string): Promise { + const response = await fetch(`${this.url}/repos/${this.context.owner}/${this.repo}/deployments`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + ref: this.context.sha, + task: 'deploy', + environment: stack, + production_environment: ['prod', 'production'].includes(stack) + }) + }); + const body = await response.json() as GitHubDeployment; + + if (!response.ok) { + if (this.context.force) { + console.log('warn - Error in Github Deployment Creation, skipping due to --force'); + return; + } + + console.error(body); + throw new Error('Could not create deployment'); + } + + this.context.deployment = body.id; + } + + async deployment_update(_stack: string, success: DeploymentState): Promise { + const response = await fetch(`${this.url}/repos/${this.context.owner}/${this.repo}/deployments/${this.context.deployment}/statuses`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + state: success + }) + }); + const body = await response.json() as unknown; + + if (!response.ok) { + if (this.context.force) { + console.log('warn - Error in Github Deployment Update, skipping due to --force'); + return body; + } + + console.error(body); + throw new Error('Could not create deployment'); + } + + return body; + } + + async deployment_delete(): Promise { + const response = await fetch(`${this.url}/repos/${this.context.owner}/${this.repo}/deployments/${this.context.deployment}`, { + method: 'DELETE', + headers: this.headers + }); + const body = await response.json() as unknown; + + if (!response.ok) { + if (this.context.force) { + console.log('warn - Error in Github Deployment Deletion skipping due to --force'); + return body; + } + + console.error(body); + throw new Error('Could not delete deployment'); + } + + return body; + } + + private deploymentState(success?: boolean): DeploymentState { + if (success === undefined) { + return 'pending'; + } + + return success ? 'success' : 'failure'; + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/lib/git.js b/lib/git.js deleted file mode 100644 index 4d23360..0000000 --- a/lib/git.js +++ /dev/null @@ -1,114 +0,0 @@ -import path from 'path'; -import cp from 'child_process'; - -/** - * @class - */ -export default class Git { - /** - * Return top level dir of a git repo - * @return {string} - */ - static root() { - const git = cp.spawnSync('git', [ - 'rev-parse', '--show-toplevel' - ]); - - if (!git.stdout) return (new Error('Is this a git repo? Could not determine Git Root Directory')); - return String(git.stdout).replace(/\n/g, ''); - } - - /** - * Get the name of the current GitRepo - * @return {string} - */ - static repo() { - return path.parse(this.root()).name; - } - - /** - * Get the name of the current git user - * @return {string} - */ - static user() { - const git = cp.spawnSync('git', [ - 'config', 'user.name' - ]); - - if (!git.stdout) return (new Error('Is this a git repo? Could not determine GitSha')); - return String(git.stdout).replace(/\n/g, ''); - } - - /** - * Get the name of the upstream git owner - * @return {string} - */ - static owner() { - const git = cp.spawnSync('git', [ - 'config', '--get', 'remote.origin.url' - ]); - - if (!String(git.stdout)) return false; - - const owner = String(git.stdout).replace(/\n/g, ''); - - if (owner.includes('git@github.com')) { - return owner - .replace(/.*git@github.com:/, '') - .replace(/\/.*/, ''); - } else if (owner.match('https://.*github.com')) { - const giturl = new URL(owner); - - return giturl.pathname - .replace('.git', '') - .slice(1) - .replace(/\/.*/, ''); - } else { - throw new Error('only origins of format: git@github.com or https://github.com are supported'); - } - } - - /** - * Get the current GitSha - * - * @returns {String} - */ - static sha() { - const git = cp.spawnSync('git', [ - '--git-dir', path.resolve(this.root(), '.git'), - 'rev-parse', 'HEAD' - ]); - - if (!git.stdout) return (new Error('Is this a git repo? Could not determine GitSha')); - return String(git.stdout).replace(/\n/g, ''); - - } - - /** - * Determine if there are uncommitted changes in the repo - * - * @returns {Boolean} - */ - static uncommitted() { - const git = cp.spawnSync('git', [ - '--git-dir', path.resolve(this.root(), '.git'), - 'status', '-s' - ]); - - return !!String(git.stdout); - } - - /** - * Determine if all commits have been pushed to remote - * - * @returns {Boolean} - */ - static pushed() { - const git = cp.spawnSync('git', [ - '--git-dir', path.resolve(this.root(), '.git'), - 'status' - ]); - - return !String(git.stdout).match(/Your branch is ahead of/); - } -} diff --git a/lib/git.ts b/lib/git.ts new file mode 100644 index 0000000..80904ab --- /dev/null +++ b/lib/git.ts @@ -0,0 +1,76 @@ +import cp from 'node:child_process'; +import path from 'node:path'; + +export default class Git { + static root(): string { + const git = cp.spawnSync('git', ['rev-parse', '--show-toplevel']); + const stdout = String(git.stdout ?? '').trim(); + + if (!stdout) { + throw new Error('Is this a git repo? Could not determine Git root directory'); + } + + return stdout; + } + + static repo(): string { + return path.parse(this.root()).name; + } + + static user(): string { + const git = cp.spawnSync('git', ['config', 'user.name']); + const stdout = String(git.stdout ?? '').trim(); + + if (!stdout) { + throw new Error('Is this a git repo? Could not determine git user'); + } + + return stdout; + } + + static owner(): string | false { + const git = cp.spawnSync('git', ['config', '--get', 'remote.origin.url']); + const stdout = String(git.stdout ?? '').trim(); + + if (!stdout) { + return false; + } + + if (stdout.includes('git@github.com')) { + return stdout + .replace(/.*git@github.com:/, '') + .replace(/\/.*/, ''); + } + + if (/https:\/\/.*github.com/.test(stdout)) { + const remoteUrl = new URL(stdout); + return remoteUrl.pathname + .replace('.git', '') + .slice(1) + .replace(/\/.*/, ''); + } + + throw new Error('only origins of format: git@github.com or https://github.com are supported'); + } + + static sha(): string { + const git = cp.spawnSync('git', ['--git-dir', path.resolve(this.root(), '.git'), 'rev-parse', 'HEAD']); + const stdout = String(git.stdout ?? '').trim(); + + if (!stdout) { + throw new Error('Is this a git repo? Could not determine GitSha'); + } + + return stdout; + } + + static uncommitted(): boolean { + const git = cp.spawnSync('git', ['--git-dir', path.resolve(this.root(), '.git'), 'status', '-s']); + return Boolean(String(git.stdout ?? '').trim()); + } + + static pushed(): boolean { + const git = cp.spawnSync('git', ['--git-dir', path.resolve(this.root(), '.git'), 'status']); + return !/Your branch is ahead of/.test(String(git.stdout ?? '')); + } +} diff --git a/lib/help.js b/lib/help.ts old mode 100755 new mode 100644 similarity index 79% rename from lib/help.js rename to lib/help.ts index bedb48f..80eea05 --- a/lib/help.js +++ b/lib/help.ts @@ -1,18 +1,17 @@ import mode from './commands.js'; -/** - * @class - */ -export default class help { - static main() { +export default class Help { + static main(): never { console.log(); console.log('Usage: deploy [--profile ] [--template ]'); console.log(' [--version] [--help]'); console.log(); - console.log('Create, manage and delete Cloudformation Resouces from the CLI'); + console.log('Create, manage and delete CloudFormation resources from the CLI'); console.log(); console.log('Subcommands:'); - for (const m in mode) console.log(` ${m.padEnd(12)} [--help] ${mode[m].short}`); + for (const name of Object.keys(mode)) { + console.log(` ${name.padEnd(12)} [--help] ${mode[name].short}`); + } console.log(); console.log('[options]:'); console.log(' --region Override default region to perform operations in'); @@ -20,7 +19,7 @@ export default class help { console.log(' with must be defined either via a .deploy file or via this flag'); console.log(' --name Override the default naming conventions of substacks'); console.log(' --template The master template should be found at "cloudformation/.template.js(on)"'); - console.log(' if the project has multiple CF Templates, they can be deployed by specifying'); + console.log(' if the project has multiple CF templates, they can be deployed by specifying'); console.log(' their location with this flag. The stack will be named:'); console.log(' --