diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9716bad..30ce786 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node-version: [ 14.x, 16.x, 18.x ] + node-version: [ 16.x, 18.x, 20.x ] os: [ windows-latest, ubuntu-latest, macOS-latest ] # Go @@ -48,7 +48,7 @@ jobs: - name: Notify uses: sarisia/actions-status-discord@v1 # Only fire alert once - if: github.ref == 'refs/heads/main' && failure() && matrix.node-version == '14.x' && matrix.os == 'ubuntu-latest' + if: github.ref == 'refs/heads/main' && failure() && matrix.node-version == '20.x' && matrix.os == 'ubuntu-latest' with: webhook: ${{ secrets.DISCORD_WEBHOOK }} title: "build and test" diff --git a/banner/index.js b/banner/index.js index a5a5c83..782f0c9 100644 --- a/banner/index.js +++ b/banner/index.js @@ -1,60 +1,49 @@ let chalk = require('chalk') let chars = require('../chars') -let initAWS = require('./init-aws') module.exports = function printBanner (params = {}) { let { cwd = process.cwd(), inventory, disableBanner, - disableRegion, - disableProfile, - needsValidCreds, version = '–', } = params let quiet = process.env.ARC_QUIET || process.env.QUIET || params.quiet if (disableBanner) return - else { - // Boilerplate - let log = (label, value) => { - if (!quiet) { - console.log(chalk.grey(`${label.padStart(12)} ${chars.buzz}`), chalk.cyan(value)) - } - } - - // Initialize config - process.env.ARC_APP_NAME = inventory.inv.app - initAWS({ inventory, needsValidCreds }) - - // App name - let name = process.env.ARC_APP_NAME || 'Architect project manifest not found' - log('App', name) - - // Region - let region = process.env.AWS_REGION || '@aws region / AWS_REGION not configured' - if (!disableRegion) { - log('Region', region) - } - // Profile - let profile = process.env.ARC_AWS_CREDS === 'env' - ? 'Set via environment' - : process.env.AWS_PROFILE || '@aws profile / AWS_PROFILE not configured' - if (!disableProfile) { - log('Profile', profile) - } - - // Caller version - // Also: set deprecation status for legacy Arc installs - log('Version', version) - - // cwd - log('cwd', cwd) - - // Space + // Boilerplate + let log = (label, value) => { if (!quiet) { - console.log() + console.log(chalk.grey(`${label.padStart(12)} ${chars.buzz}`), chalk.cyan(value)) } } + + // App name + let name = inventory.inv.app || 'Architect project manifest not found' + log('App', name) + + // Region + let region = inventory.inv?.aws?.region || + process.env.AWS_REGION || + 'Region not configured' + log('Region', region) + + // Profile + let credsViaEnv = process.env.AWS_ACCESS_KEY_ID ? 'Set via environment' : undefined + let profile = credsViaEnv || + inventory.inv?.aws?.profile || + process.env.AWS_PROFILE || + 'Not configured / default' + log('Profile', profile) + + // Caller version + // Also: set deprecation status for legacy Arc installs + log('Version', version) + + // cwd + log('cwd', cwd) + + // Space + if (!quiet) console.log() } diff --git a/banner/init-aws.js b/banner/init-aws.js deleted file mode 100644 index 1e41bb3..0000000 --- a/banner/init-aws.js +++ /dev/null @@ -1,116 +0,0 @@ -let { existsSync: exists } = require('fs') -let homeDir = require('os').homedir() -let { join } = require('path') -let updater = require('../updater') - -/** - * Initialize AWS configuration, in order of preference: - * - @aws pragma + ~/.aws/credentials file - * - Environment variables - * - Dummy creds (if absolutely necessary) - */ -module.exports = function initAWS ({ inventory, needsValidCreds = true }) { - // AWS SDK intentionally not added to package deps; assume caller already has it - // eslint-disable-next-line - try { require('aws-sdk/lib/maintenance_mode_message').suppress = true } - catch { /* Noop */ } - // eslint-disable-next-line - let aws = require('aws-sdk') - let credentialsMethod = 'SharedIniFileCredentials' - let { inv } = inventory - try { - let defaultCredsPath = join(homeDir, '.aws', 'credentials') - let envCredsPath = process.env.AWS_SHARED_CREDENTIALS_FILE - let credsPath = envCredsPath || defaultCredsPath - let credsExists = exists(envCredsPath) || exists(defaultCredsPath) - // Inventory always sets a dfeault region if not specified - process.env.AWS_REGION = inv.aws.region - /** - * Always ensure we end with a final sanity check on loaded credentials - */ - // Allow local cred file to be overriden by env vars - let envOverride = process.env.ARC_AWS_CREDS === 'env' - if (credsExists && !envOverride) { - let profile = process.env.AWS_PROFILE - aws.config.credentials = [] - if (inv.aws && inv.aws.profile) { - process.env.AWS_PROFILE = profile = inv.aws.profile - } - - let init = new aws.IniLoader() - let opts = { - filename: credsPath - } - - let profileData = init.loadFrom(opts) - if (!profile) profile = process.env.AWS_PROFILE = 'default' - process.env.ARC_AWS_CREDS = 'missing' - - if (profileData[profile]) { - process.env.ARC_AWS_CREDS = 'profile' - - if (profileData[profile].credential_process) credentialsMethod = 'ProcessCredentials' - aws.config.credentials = new aws[credentialsMethod]({ - ...opts, - profile: process.env.AWS_PROFILE - }) - } - else { - delete process.env.AWS_PROFILE - } - } - else { - let hasEnvVars = process.env.AWS_ACCESS_KEY_ID && - process.env.AWS_SECRET_ACCESS_KEY - if (hasEnvVars) { - process.env.ARC_AWS_CREDS = 'env' - let params = { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY - } - if (process.env.AWS_SESSION_TOKEN) { - params.sessionToken = process.env.AWS_SESSION_TOKEN - } - aws.config.credentials = new aws.Credentials(params) - } - } - credentialCheck() - /** - * Final credential check to ensure we meet the cred needs of Arc various packages - * - Packages that **need** valid creds should be made aware that none are available (ARC_AWS_CREDS = 'missing') - * - Others that **do not need** valid creds should work fine when supplied with dummy creds (or none at all, but we'll backfill dummy creds jic) - */ - function credentialCheck () { - let creds = aws.config.credentials - let invalidCreds = Array.isArray(creds) && !creds.length - let noCreds = !creds || invalidCreds || process.env.ARC_AWS_CREDS == 'missing' - if (noCreds && needsValidCreds) { - // Set missing creds flag and let consuming modules handle as necessary - process.env.ARC_AWS_CREDS = 'missing' - } - else if (noCreds && !needsValidCreds) { - /** - * Any creds will do! (e.g. Sandbox DynamoDB) - * - Be sure we backfill Lambda's prepopulated env vars - * - sessionToken / AWS_SESSION_TOKEN is optional, skip so as not to introduce unintended side-effects - */ - process.env.ARC_AWS_CREDS = 'dummy' - process.env.AWS_ACCESS_KEY_ID = 'xxx' - process.env.AWS_SECRET_ACCESS_KEY = 'xxx' - aws.config.credentials = new aws.Credentials({ - accessKeyId: 'xxx', - secretAccessKey: 'xxx' - }) - } - // If no creds, always unset profile to prevent misleading claims about profile state - if (noCreds) { - delete process.env.AWS_PROFILE - } - } - } - catch (e) { - // Don't exit process here; caller should be responsible - let update = updater('Startup') - update.err(e) - } -} diff --git a/changelog.md b/changelog.md index ca234d5..0cb157c 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,26 @@ --- +## [4.0.0] 2023-10-19 + +### Added + +- Added `checkCreds` method for manually performing basic AWS credential checks + + +### Changed + +- Initializing the Architect banner is significantly faster, as it no longer has any interactions with `aws-sdk` +- Breaking change: banner initialization no longer has any direct responsibility for credential checking + - Related: banner initialization no longer mutates `AWS_PROFILE`, or uses `ARC_AWS_CREDS` as a signal to other modules about credential loading + - Modules relying on the banner for credential-related operations must review the changes and refactor accordingly +- Banner initialization no longer utilizes `disableRegion` or `disableProfile` params when printing +- Transitioned from `aws-sdk` to [`aws-lite`](https://aws-lite.org) +- Added Node.js 20.x to test matrix +- Breaking change: removed support for Node.js 14.x (now EOL, and no longer available to created in AWS Lambda) + +--- + ## [3.1.7 - 3.1.9] 2023-04-22 ### Changed diff --git a/check-creds/index.js b/check-creds/index.js new file mode 100644 index 0000000..9f74887 --- /dev/null +++ b/check-creds/index.js @@ -0,0 +1,29 @@ +/** + * Credential check + * - aws-lite requires credentials to initialize + * - Architect needs credentials for some things (e.g. Deploy), but also has a variety of offline workflows that interface with AWS service API emulators (e.g. Sandbox) + * - Thus, sometimes it's ok to use dummy creds, but sometimes we need to halt (via this util) + */ +module.exports = function checkAwsCredentials (params, callback) { + // eslint-disable-next-line + let awsLite = require('@aws-lite/client') + let { inventory } = params + + let promise + if (!callback) { + promise = new Promise((res, rej) => { + callback = (err, result) => err ? rej(err) : res(result) + }) + } + + let errMsg = 'Valid AWS credentials needed to continue; missing or invalid credentials' + awsLite({ + autoloadPlugins: false, + profile: inventory.inv?.aws?.profile, // aws-lite falls back to AWS_PROFILE or 'default' if undefined + region: 'us-west-1', + }) + .then(() => callback()) + .catch(() => callback(Error(errMsg))) + + return promise +} diff --git a/index.js b/index.js index eac2064..f6f5147 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ let banner = require('./banner') let chars = require('./chars') +let checkCreds = require('./check-creds') let deepFrozenCopy = require('./deep-frozen-copy') let fingerprint = require('./fingerprint') let getLambdaName = require('./get-lambda-name') @@ -8,8 +9,9 @@ let toLogicalID = require('./to-logical-id') let updater = require('./updater') module.exports = { - banner, // Prints banner and loads basic env vars and AWS creds + banner, // Prints banner chars, // Returns platform appropriate characters for CLI UI printing + checkCreds, // Performs basic AWS credential check deepFrozenCopy, // Fast deep frozen object copy fingerprint, // Generates public/static.json for `@static fingerprint true` getLambdaName, // Get Lambda name from Arc path diff --git a/package.json b/package.json index b288993..9c6d0c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@architect/utils", - "version": "3.1.9", + "version": "4.0.0-RC.4", "description": "Common utility functions", "main": "index.js", "repository": { @@ -20,8 +20,9 @@ "author": "Brian LeRoux ", "license": "Apache-2.0", "dependencies": { + "@aws-lite/client": "^0.15.1", "chalk": "4.1.2", - "glob": "~10.2.2", + "glob": "~10.3.10", "path-sort": "~0.1.0", "restore-cursor": "3.1.0", "run-series": "~1.1.9", @@ -30,15 +31,13 @@ }, "devDependencies": { "@architect/eslint-config": "~2.1.2", - "@architect/inventory": "~3.4.3", - "aws-sdk": "^2.1363.0", + "@architect/inventory": "~3.6.5", "cross-env": "~7.0.3", - "eslint": "~8.49.0", + "eslint": "~8.56.0", "proxyquire": "~2.1.3", - "sinon": "~15.0.4", - "tap-arc": "~1.0.0", - "tape": "~5.6.6", - "temp-write": "4.0.0" + "sinon": "~17.0.1", + "tap-arc": "1.1.0", + "tape": "~5.7.4" }, "eslintConfig": { "extends": "@architect/eslint-config" diff --git a/test/banner/check-creds-test.js b/test/banner/check-creds-test.js new file mode 100644 index 0000000..61198e1 --- /dev/null +++ b/test/banner/check-creds-test.js @@ -0,0 +1,77 @@ +let test = require('tape') +let checkCreds = require('../../check-creds') + +function reset (t) { + let envVars = [ + 'ARC_AWS_CREDS', + 'AWS_PROFILE', + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_SHARED_CREDENTIALS_FILE', + ] + envVars.forEach(v => delete process.env[v]) + envVars.forEach(v => { + if (process.env[v]) t.fail(`Found errant env var: ${v}`) + }) +} + +let inventory = { inv: { aws: {} } } + +test('Set up env', t => { + t.plan(1) + t.ok(checkCreds, 'Found checkCreds') +}) + +test('Credential checks (async)', async t => { + t.plan(2) + + // Count on aws-lite finding creds (via env) + process.env.AWS_ACCESS_KEY_ID = 'yo' + process.env.AWS_SECRET_ACCESS_KEY = 'yo' + try { + await checkCreds({ inventory }) + t.pass('No credential loading error reported') + } + catch (err) { + t.fail(err) + } + + // Fail a cred check + reset(t) + process.env.AWS_PROFILE = 'random_profile_name_that_does_not_exist' + try { + await checkCreds({ inventory }) + t.fail('Should have errored') + } + catch (err) { + t.ok(err, 'Reported credential loading error') + } + reset(t) +}) + +test('Credential checks (callback, via env)', t => { + t.plan(1) + + // Count on aws-lite finding creds (via env) + process.env.AWS_ACCESS_KEY_ID = 'yo' + process.env.AWS_SECRET_ACCESS_KEY = 'yo' + checkCreds({ inventory }, err => { + reset(t) + if (err) t.fail(err) + else t.pass('No credential loading error reported') + }) +}) + +test('Credential checks (callback failure, via profile)', t => { + t.plan(1) + // Fail a cred check + reset(t) + process.env.AWS_PROFILE = 'random_profile_name_that_does_not_exist' + checkCreds({ inventory }, err => { + reset(t) + if (err) t.ok(err, 'Reported credential loading error') + else t.fail('Should have errored') + }) +}) diff --git a/test/banner/init-aws-test.js b/test/banner/init-aws-test.js deleted file mode 100644 index ffe8279..0000000 --- a/test/banner/init-aws-test.js +++ /dev/null @@ -1,162 +0,0 @@ -let test = require('tape') -require('aws-sdk/lib/maintenance_mode_message').suppress = true -let aws = require('aws-sdk') -let tmpFile = require('temp-write') -let mockCredsContent = require('./mock-aws-creds-file') -let _inventory = require('@architect/inventory') -let mockCredsFile = tmpFile(mockCredsContent) -let credsExists = true -let fs = { - existsSync: function () { - return credsExists - } -} -let profile = 'architect' -let secret = 'so-secret' -let sessionToken = 'a-random-token' -let proxyquire = require('proxyquire').noCallThru() -let initAWS = proxyquire('../../banner/init-aws', { - 'fs': fs, - '@noCallThru': true -}) - -function reset (t) { - let envVars = [ - 'ARC_AWS_CREDS', - 'AWS_PROFILE', - 'AWS_REGION', - 'AWS_ACCESS_KEY_ID', - 'AWS_SECRET_ACCESS_KEY', - 'AWS_SESSION_TOKEN', - 'AWS_SHARED_CREDENTIALS_FILE' - ] - envVars.forEach(v => {delete process.env[v]}) - envVars.forEach(v => { - if (process.env[v]) t.fail(`Found errant env var: ${v}`) - }) - aws.config = { credentials: null } -} - -/** - * AWS credential initialization tests are focused on our credential population - * These aren't intended to be integration tests for aws-sdk credentials methods (`SharedIniFileCredentials` + `Credentials`, etc.) - */ -test('Set region', async t => { - reset(t) - t.plan(2) - let region = 'us-west-1' - let rawArc = `@app\nappname\n@aws\nregion ${region}` - let inventory = await _inventory({ rawArc }) - initAWS({ inventory }) - t.equal(process.env.AWS_REGION, region, `AWS region set by .arc`) - // Leave AWS_REGION env var set for next test - - rawArc = `@app\nappname` - inventory = await _inventory({ rawArc }) - initAWS({ inventory }) - t.equal(process.env.AWS_REGION, region, `AWS region set by env`) - reset(t) -}) - -test('Credentials supplied by ~/.aws/credentials', async t => { - t.plan(12) - let rawArc - let inventory - - // Profile found, no env override - // Profile specified by .arc - process.env.AWS_SHARED_CREDENTIALS_FILE = await mockCredsFile - rawArc = `@app\nappname\n@aws\nprofile ${profile}` - inventory = await _inventory({ rawArc }) - - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'profile', `ARC_AWS_CREDS set to 'profile'`) - t.equal(process.env.AWS_PROFILE, profile, `AWS_PROFILE set by .arc`) - t.equal(aws.config.credentials.accessKeyId, 'architect_mock_access_key', `AWS config not mutated`) - reset(t) - - // Profile specified by env - process.env.AWS_SHARED_CREDENTIALS_FILE = await mockCredsFile - process.env.AWS_PROFILE = profile - rawArc = `@app\nappname` - inventory = await _inventory({ rawArc }) - - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'profile', `ARC_AWS_CREDS set to 'profile'`) - t.equal(process.env.AWS_PROFILE, profile, `AWS_PROFILE set by env`) - t.equal(aws.config.credentials.accessKeyId, 'architect_mock_access_key', `AWS config not mutated`) - reset(t) - - // Process Credential Respected - process.env.AWS_SHARED_CREDENTIALS_FILE = await mockCredsFile - process.env.AWS_PROFILE = 'architect_process' - - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'profile', `ARC_AWS_CREDS set to 'profile'`) - t.equal(process.env.AWS_PROFILE, 'architect_process', `AWS_PROFILE set by env`) - console.log(aws.config.credentials) - t.notOk(aws.config.credentials.accessKeyId, `AWS access key not set via processCred`) - reset(t) - - // Profile defaulted - process.env.AWS_SHARED_CREDENTIALS_FILE = await mockCredsFile - - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'profile', `ARC_AWS_CREDS set to 'profile'`) - t.equal(process.env.AWS_PROFILE, 'default', `AWS_PROFILE set to default`) - t.equal(aws.config.credentials.accessKeyId, 'default_mock_access_key', `AWS config not mutated`) - reset(t) -}) - -test('Credentials supplied by env vars', async t => { - t.plan(10) - - // Credentials file found, env override - process.env.ARC_AWS_CREDS = 'env' - process.env.AWS_ACCESS_KEY_ID = profile - process.env.AWS_SECRET_ACCESS_KEY = 'so-secret' - let rawArc = `@app\nappname\n@aws\nprofile ${profile}` - let inventory = await _inventory({ rawArc }) - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'env', `ARC_AWS_CREDS set to 'env'`) - t.notOk(process.env.AWS_PROFILE, `AWS_PROFILE not set`) - t.equal(aws.config.credentials.accessKeyId, profile, `AWS config access key set`) - t.equal(aws.config.credentials.secretAccessKey, secret, `AWS config secret key set`) - t.notOk(aws.config.credentials.sessionToken, `AWS config sessionToken not set`) - reset(t) - - process.env.ARC_AWS_CREDS = 'env' - process.env.AWS_ACCESS_KEY_ID = profile - process.env.AWS_SECRET_ACCESS_KEY = 'so-secret' - process.env.AWS_SESSION_TOKEN = sessionToken - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'env', `ARC_AWS_CREDS set to 'env'`) - t.notOk(process.env.AWS_PROFILE, `AWS_PROFILE not set`) - t.equal(aws.config.credentials.accessKeyId, profile, `AWS config access key set`) - t.equal(aws.config.credentials.secretAccessKey, secret, `AWS config secret key set`) - t.equal(aws.config.credentials.sessionToken, sessionToken, `AWS config sessionToken set`) - reset(t) -}) - -test('Final credential check', async t => { - t.plan(9) - process.env.HIDE_HOME = true - process.env.AWS_SHARED_CREDENTIALS_FILE = await tmpFile('') - let rawArc = `@app\nappname\n@aws\nprofile does-not-exist` - let inventory = await _inventory({ rawArc }) - initAWS({ inventory }) - t.equal(process.env.ARC_AWS_CREDS, 'missing', `ARC_AWS_CREDS set to 'missing'`) - t.notOk(process.env.AWS_PROFILE, `AWS_PROFILE deleted`) - t.notOk(Object.keys(aws.config.credentials).length, `AWS config not present`) - reset(t) - - process.env.AWS_SHARED_CREDENTIALS_FILE = await tmpFile('') - initAWS({ inventory, needsValidCreds: false }) - t.equal(process.env.ARC_AWS_CREDS, 'dummy', `ARC_AWS_CREDS set to 'dummy'`) - t.equal(process.env.AWS_ACCESS_KEY_ID, 'xxx', `AWS_ACCESS_KEY_ID backfilled`) - t.equal(process.env.AWS_SECRET_ACCESS_KEY, 'xxx', `AWS_SECRET_ACCESS_KEY backfilled`) - t.notOk(process.env.AWS_PROFILE, `AWS_PROFILE deleted`) - t.equal(aws.config.credentials.accessKeyId, 'xxx', `AWS config.credentials.accessKeyId backfilled to 'xxx'`) - t.equal(aws.config.credentials.secretAccessKey, 'xxx', `AWS config.credentials.secretAccessKey backfilled to 'xxx'`) - reset(t) -})