diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45242a2..d7c8712 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,13 +5,13 @@ on: release: types: [published] -jobs: +permissions: + contents: read - publish: - name: Publish to NPM +jobs: + prePublishPackageTest: + name: Prepublish package test runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -20,9 +20,7 @@ jobs: uses: actions/cache@v4 id: cacheNpm with: - path: | - ~/.npm - node_modules + path: ~/.npm key: npm-v22-${{ runner.os }}-refs/heads/main-${{ hashFiles('package.json') }} - name: Install Node.js and npm @@ -31,14 +29,8 @@ jobs: node-version: 22.x registry-url: https://registry.npmjs.org - # Normally we have a guarantee that deps are already there, still it may not be the case when: - # - `main` build for same commit failed (and we still pushed tag manually) - # - We've pushed tag manually before `main` build finalized - name: Install dependencies - if: steps.cacheNpm.outputs.cache-hit != 'true' - run: | - npm update --no-save - npm update --save-dev --no-save + run: npm install # Store the name of the release # See https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions @@ -46,9 +38,46 @@ jobs: - run: npm version $RELEASE_VERSION --no-git-tag-version + - name: Unit tests + run: npm test + + - name: Verify package contents + run: npm pack --dry-run + + - name: Build publish tarball + run: echo "PACKAGE_TGZ=$(npm pack --silent)" >> $GITHUB_ENV + + - name: Packed install smoke test + run: node ./scripts/ci/pack-smoke.js + env: + PACKAGE_TGZ: ${{ env.PACKAGE_TGZ }} + + - name: Upload tested tarball + uses: actions/upload-artifact@v4 + with: + name: npm-package + path: ${{ env.PACKAGE_TGZ }} + if-no-files-found: error + + npmPublish: + name: Publish to NPM + needs: prePublishPackageTest + runs-on: ubuntu-latest + steps: + - name: Download tested tarball + uses: actions/download-artifact@v4 + with: + name: npm-package + + - name: Install Node.js and npm + uses: actions/setup-node@v4 + with: + node-version: 22.x + registry-url: https://registry.npmjs.org + - name: Publish new version # Note: Setting NODE_AUTH_TOKEN as job|workspace wide env var won't work # as it appears actions/setup-node sets own value env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public --tag=latest + run: npm publish ./*.tgz --access public --tag=latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b81d2d3..4a24c1b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,39 +1,116 @@ -name: Tests +name: Validate on: - push: pull_request: + branches: [main] + push: + branches: [main] env: FORCE_COLOR: 1 jobs: - linuxNode16: - name: '[Linux] [Node 16] Tests' + linuxNode22: + name: '[Linux] Node 22: Lint, Formatting, Unit & packaging tests' runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + fetch-depth: 30 + + - name: Retrieve last main commit (for `git diff` purposes) + run: | + git checkout -b pr + git fetch --prune --depth=30 origin +refs/heads/main:refs/remotes/origin/main + git checkout main + git checkout pr - name: Retrieve dependencies from cache id: cacheNpm - uses: actions/cache@v2 + uses: actions/cache@v4 with: - path: | - ~/.npm - node_modules - key: npm-v16-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }} - restore-keys: npm-v16-${{ runner.os }}-${{ github.ref }}- + path: ~/.npm + key: npm-v22-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }} + restore-keys: | + npm-v22-${{ runner.os }}-${{ github.ref }}- + npm-v22-${{ runner.os }}-refs/heads/main- - name: Install Node.js and npm - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: 22.x - name: Install dependencies - if: steps.cacheNpm.outputs.cache-hit != 'true' - run: | - npm update --no-save - npm update --save-dev --no-save + run: npm install + + - name: Validate formatting + run: npm run prettier-check:updated + + - name: Validate lint rules + run: npm run lint:updated + + - name: Unit tests + run: npm test + + - name: Verify package contents + run: npm pack --dry-run + + - name: Packed install smoke test + run: node ./scripts/ci/pack-smoke.js + + windowsNode22: + name: '[Windows] Node 22: Unit tests' + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Retrieve dependencies from cache + id: cacheNpm + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-v20-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }} + restore-keys: | + npm-v20-${{ runner.os }}-${{ github.ref }}- + npm-v20-${{ runner.os }}-refs/heads/main- + + - name: Install Node.js and npm + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Install dependencies + run: npm install + + - name: Unit tests + run: npm test + + linuxNode20: + name: '[Linux] Node 20: Unit tests' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Retrieve dependencies from cache + id: cacheNpm + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-v20-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }} + restore-keys: | + npm-v20-${{ runner.os }}-${{ github.ref }}- + npm-v20-${{ runner.os }}-refs/heads/main- + + - name: Install Node.js and npm + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Install dependencies + run: npm install + - name: Unit tests run: npm test diff --git a/.gitignore b/.gitignore index 7193114..282a601 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ npm-debug.log package-lock.json yarn.lock node_modules -.serverless \ No newline at end of file +.serverless diff --git a/.npmignore b/.npmignore index 93f4e1d..0a9443e 100644 --- a/.npmignore +++ b/.npmignore @@ -2,5 +2,7 @@ /.editorconfig /.prettierignore /prettier.config.js -/scripts/pkg +/dist +/.serverless +/scripts /test diff --git a/README.md b/README.md index 913e935..ec87159 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ This repository contains the code for Serverless Framework Compose. +Compose requires Node.js 20 or later. + +There is no standalone binary version, the package is only available via NPM. + +```bash +npm install -g @osls/compose +osls-compose --help +``` + +Available commands: `osls-compose`, `oslsc`. + Check out the [Serverless Framework Compose documentation](https://github.com/oss-serverless/serverless/blob/main/docs/guides/compose.md).

diff --git a/bin/serverless-compose b/bin/serverless-compose index 9b1eb3d..077e047 100755 --- a/bin/serverless-compose +++ b/bin/serverless-compose @@ -1,12 +1,33 @@ #!/usr/bin/env node -// Used to measure length of the whole command -EvalError.$composeCommandStartTime = process.hrtime(); +'use strict'; -const { runComponents } = require('../src') +// `EvalError` is used to not pollute global namespace but still have the value accessible globally +const isMainModule = !EvalError.$composeCommandStartTime; +if (isMainModule) EvalError.$composeCommandStartTime = process.hrtime(); +const isSupportedNodeVersion = require('../src/cli/is-supported-node-version'); +const minimumSupportedVersionMajor = 20; +const minimumSupportedVersionMinor = 0; -// Run -;(async () => { - return runComponents() -})(); +if (!isSupportedNodeVersion(process.version)) { + const composeVersion = require('../package.json').version; + process.stderr.write( + `Error: Serverless Framework Compose v${composeVersion} does not support ` + + `Node.js ${process.version}. Please upgrade Node.js to the latest ` + + `LTS release. Minimum supported version: ` + + `v${minimumSupportedVersionMajor}.${minimumSupportedVersionMinor}.0.\n` + ); + process.exit(1); +} + +const crashAsync = (error) => { + process.nextTick(() => { + throw error; + }); +}; + +const { runComponents } = require('../src'); + +// Compose is npm-only, so there is no standalone or local-fallback bootstrap path here. +runComponents().catch(crashAsync); diff --git a/components/framework/index.js b/components/framework/index.js index 5c79b6d..0bacc2a 100644 --- a/components/framework/index.js +++ b/components/framework/index.js @@ -237,13 +237,6 @@ class ServerlessFramework { args.push('--region', this.inputs.region); } - // Patch required for standalone distribution of Serverless - // Needed because of the behavior of `pkg` when invoking itself via subprocess - // https://github.com/vercel/pkg/issues/897 - if (command === 'serverless' && process.pkg) { - args = [process.pkg.entrypoint, ...args]; - } - this.context.logVerbose(`Running "${command} ${args.join(' ')}"`); return new Promise((resolve, reject) => { const child = spawn(command, args, { diff --git a/package.json b/package.json index e0df6d4..2acf709 100644 --- a/package.json +++ b/package.json @@ -3,87 +3,367 @@ "version": "1.3.0", "description": "Deploy and orchestrate multiple Serverless Framework services in monorepositories.", "main": "src/index.js", - "repository": "oss-serverless/compose", + "repository": { + "type": "git", + "url": "git+https://github.com/oss-serverless/compose.git" + }, "bin": { - "osls-compose": "./bin/serverless-compose", - "oslsc": "./bin/serverless-compose" + "osls-compose": "bin/serverless-compose", + "oslsc": "bin/serverless-compose" }, "scripts": { "lint": "eslint .", "prettify": "prettier --write --ignore-path .gitignore \"**/*.{css,html,js,json,md,yaml,yml}\"", "prettier-check:updated": "pipe-git-updated --ext=css --ext=html --ext=js --ext=json --ext=md --ext=yaml --ext=yml --base=main -- prettier -c", "lint:updated": "pipe-git-updated --ext=js --base=main -- eslint", - "pkg:build": "node ./scripts/pkg/build.js", - "test": "mocha \"test/**/*.js\"" + "test": "mocha \"test/**/*.test.js\"" }, "license": "MIT", "dependencies": { - "@aws-sdk/client-cloudformation": "^3.137.0", - "@aws-sdk/client-s3": "^3.137.0", - "@serverless-components/utils-aws": "^0.1.0", - "@serverless/utils": "^6.7.0", + "@aws-sdk/client-cloudformation": "^3.1034.0", + "@aws-sdk/client-s3": "^3.1034.0", + "@aws-sdk/client-sts": "^3.1034.0", + "@aws-sdk/credential-provider-env": "^3.972.29", + "@aws-sdk/credential-provider-imds": "^3.370.0", + "@aws-sdk/credential-provider-ini": "^3.972.33", + "@aws-sdk/credential-provider-process": "^3.972.29", + "@aws-sdk/credential-provider-sso": "^3.972.33", + "@aws-sdk/credential-provider-web-identity": "^3.972.33", + "@aws-sdk/property-provider": "^3.366.0", + "@dagrejs/graphlib": "^3.0.4", "ajv": "^8.11.0", "chalk": "^4.1.2", "child-process-ext": "^2.1.1", "ci-info": "^3.3.2", + "cli-progress-footer": "^2.3.2", "cli-cursor": "^3", + "d": "^1.0.1", "cross-spawn": "^7.0.3", + "event-emitter": "^0.3.5", + "ext": "^1.7.0", "fs-extra": "^10.1.0", "globby": "^11.1.0", - "graphlib": "^2.1.8", "hasha": "^5.2.2", "is-interactive": "^1", "is-unicode-supported": "^0.1", "js-yaml": "^4.1.0", + "log": "^6.3.1", + "log-node": "^8.0.3", + "memoizee": "^0.4.15", "minimist": "^1.2.6", - "node-fetch": "^2.6.7", "p-limit": "^3.1.0", "path2": "^0.1.0", - "prettyoutput": "^1.2.0", "promise-queue": "^2.2.5", "ramda": "^0.28.0", "semver": "^7.3.7", "signal-exit": "^3.0.7", "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", "traverse": "^0.6.6", "type": "^2.6.1", + "uni-global": "^1.0.0", "uuid": "^8.3.2" }, "devDependencies": { - "@serverless/eslint-config": "^4.0.1", - "@serverless/test": "^10.0.4", "@types/mocha": "^9.1.1", "aws-sdk-client-mock": "^0.6.2", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", - "eslint": "^8.20.0", - "eslint-plugin-import": "^2.26.0", + "eslint": "^8.57.1", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-n": "^17.24.0", "git-list-updated": "^1.2.1", - "mocha": "^9.2.2", - "pkg": "^5.8.0", - "prettier": "^2.7.1", + "mocha": "^11.7.5", + "prettier": "^2.8.8", "proxyquire": "^2.1.3", "sinon": "^13.0.2", "sinon-chai": "^3.7.0" }, "eslintConfig": { - "extends": "@serverless/eslint-config/node", + "extends": [ + "eslint:recommended", + "plugin:n/recommended" + ], + "plugins": [ + "import" + ], "root": true, + "env": { + "es2023": true, + "node": true + }, + "globals": { + "fetch": "readonly" + }, + "rules": { + "array-callback-return": "error", + "block-scoped-var": "error", + "camelcase": [ + "error", + { + "properties": "never" + } + ], + "consistent-return": "error", + "curly": [ + "error", + "multi-line" + ], + "default-case": [ + "error", + { + "commentPattern": "^no default$" + } + ], + "dot-notation": [ + "error", + { + "allowKeywords": true + } + ], + "eqeqeq": [ + "error", + "allow-null" + ], + "guard-for-in": "error", + "import/export": "error", + "import/newline-after-import": "error", + "import/no-amd": "error", + "import/no-duplicates": "error", + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": [ + "**/*.test.js", + "**/scripts/**", + "**/test/**", + "**/tests/**", + "prettier.config.js" + ] + } + ], + "import/no-mutable-exports": "error", + "import/no-named-as-default": "error", + "import/no-named-as-default-member": "error", + "import/no-unresolved": [ + "error", + { + "commonjs": true + } + ], + "import/prefer-default-export": "error", + "new-cap": [ + "error", + { + "newIsCap": true + } + ], + "no-array-constructor": "error", + "no-caller": "error", + "no-confusing-arrow": [ + "error", + { + "allowParens": false + } + ], + "no-console": "error", + "no-duplicate-imports": "error", + "no-else-return": "error", + "no-empty-function": [ + "error", + { + "allow": [ + "arrowFunctions", + "functions", + "methods" + ] + } + ], + "no-eval": "error", + "no-extra-bind": "error", + "no-extra-label": "error", + "no-extra-semi": "off", + "no-implied-eval": "error", + "no-iterator": "error", + "no-label-var": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-mixed-spaces-and-tabs": "off", + "no-multi-str": "error", + "no-nested-ternary": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-object": "error", + "no-new-require": "error", + "no-new-wrappers": "error", + "no-octal-escape": "error", + "no-proto": "error", + "no-restricted-syntax": [ + "error", + "DebuggerStatement", + "ForInStatement", + "WithStatement" + ], + "no-return-assign": "error", + "no-script-url": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "error", + "no-throw-literal": "error", + "no-undef-init": "error", + "no-unexpected-multiline": "off", + "no-unneeded-ternary": [ + "error", + { + "defaultAssignment": false + } + ], + "no-unused-expressions": [ + "error" + ], + "no-unused-vars": [ + "error", + { + "vars": "local", + "args": "after-used" + } + ], + "no-useless-computed-key": "error", + "no-useless-concat": "error", + "no-useless-constructor": "error", + "no-useless-rename": [ + "error", + { + "ignoreDestructuring": false, + "ignoreImport": false, + "ignoreExport": false + } + ], + "no-var": "error", + "no-void": "error", + "object-shorthand": [ + "error", + "always", + { + "ignoreConstructors": false, + "avoidQuotes": true + } + ], + "one-var": [ + "error", + "never" + ], + "operator-assignment": [ + "error", + "always" + ], + "quotes": [ + "error", + "single", + { + "avoidEscape": true + } + ], + "prefer-arrow-callback": [ + "error", + { + "allowNamedFunctions": false, + "allowUnboundThis": true + } + ], + "prefer-const": [ + "error", + { + "destructuring": "any", + "ignoreReadBeforeAssign": true + } + ], + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + "radix": "error", + "require-atomic-updates": "off", + "vars-on-top": "error", + "spaced-comment": [ + "error", + "always", + { + "exceptions": [ + "-", + "+" + ], + "markers": [ + "=", + "!" + ] + } + ], + "strict": [ + "error", + "safe" + ], + "yoda": "error", + "n/no-unsupported-features/node-builtins": [ + "error", + { + "allowExperimental": true + } + ], + "n/no-extraneous-require": "off", + "n/no-unpublished-require": "off", + "n/no-missing-require": "off", + "n/no-process-exit": "off", + "n/no-deprecated-api": "off", + "n/hashbang": "off" + }, "ignorePatterns": [ "demo/*" + ], + "overrides": [ + { + "files": [ + "**/*.mjs" + ], + "parserOptions": { + "sourceType": "module" + } + }, + { + "files": [ + "**/*.test.js", + "**/test/**" + ], + "env": { + "mocha": true + }, + "rules": { + "no-unused-expressions": "off" + } + } ] }, "mocha": { "require": [ - "@serverless/test/setup/patch", - "@serverless/test/setup/log", - "@serverless/test/setup/mock-homedir", - "@serverless/test/setup/mock-cwd", - "@serverless/test/setup/restore-env" + "./test/lib/setup/patch", + "./test/lib/setup/log", + "./test/lib/setup/mock-homedir", + "./test/lib/setup/mock-cwd", + "./test/lib/setup/restore-env" ], "timeout": 10000 }, "engines": { - "node": ">=12.12" - } + "node": ">=20.0.0" + }, + "directories": { + "test": "test" + }, + "keywords": [], + "author": "", + "type": "commonjs", + "bugs": { + "url": "https://github.com/oss-serverless/compose/issues" + }, + "homepage": "https://github.com/oss-serverless/compose#readme" } diff --git a/prettier.config.js b/prettier.config.js index 5f45afa..ad49949 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -1,3 +1,9 @@ 'use strict'; -module.exports = require('@serverless/eslint-config/prettier.config'); +module.exports = { + endOfLine: 'lf', + printWidth: 100, + quoteProps: 'consistent', + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/scripts/ci/pack-smoke.js b/scripts/ci/pack-smoke.js new file mode 100644 index 0000000..26d37ff --- /dev/null +++ b/scripts/ci/pack-smoke.js @@ -0,0 +1,50 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + shell: process.platform === 'win32', + stdio: 'inherit', + }); + + if (result.status !== 0) { + process.exit(result.status || 1); + } +}; + +const pack = () => { + const result = spawnSync('npm', ['pack', '--silent'], { + cwd: process.cwd(), + encoding: 'utf8', + shell: process.platform === 'win32', + }); + + if (result.status !== 0) { + process.stderr.write(result.stderr || 'npm pack failed\n'); + process.exit(result.status || 1); + } + + return result.stdout.trim(); +}; + +const packageFileName = process.env.PACKAGE_TGZ || pack(); +const packagePath = path.resolve(packageFileName); +const shouldDeletePackage = !process.env.PACKAGE_TGZ; +const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'compose-pack-')); + +try { + run('npm', ['init', '-y'], { cwd: tempDirectory }); + run('npm', ['install', packagePath], { cwd: tempDirectory }); + run('npx', ['--no-install', 'osls-compose', '--help'], { cwd: tempDirectory }); +} finally { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + if (shouldDeletePackage) { + fs.rmSync(packagePath, { force: true }); + } +} diff --git a/scripts/pkg/build.js b/scripts/pkg/build.js deleted file mode 100644 index 768dfbf..0000000 --- a/scripts/pkg/build.js +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const path = require('path'); -const spawn = require('child-process-ext/spawn'); -const fse = require('fs-extra'); - -const componentsPath = path.join(__dirname, '../..'); -const spawnOptions = { cwd: componentsPath, stdio: 'inherit' }; - -(async () => { - // To bundle npm with a binary we need to install it - process.stdout.write('Install npm\n'); - // Hard code npm version to one that comes with lastest Node.js - // It's due to fact that npm tends to issue buggy releases - // Node.js confirms on given version before including it within its bundle - // Version mappings reference: https://nodejs.org/en/download/releases/ - await spawn('npm', ['install', '--no-save', 'npm@8.1.2'], spawnOptions); - - try { - process.stdout.write('Build binaries\n'); - await spawn( - 'node', - [ - './node_modules/.bin/pkg', - '-c', - 'scripts/pkg/config.js', - '--targets', - 'node16-linux-x64,node16-mac-x64', - '--out-path', - 'dist', - 'bin/bin', - ], - spawnOptions - ); - } finally { - await fse.remove(path.join(componentsPath, 'node_modules/npm')); - } -})(); diff --git a/scripts/pkg/config.js b/scripts/pkg/config.js deleted file mode 100644 index 16d5ea5..0000000 --- a/scripts/pkg/config.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -module.exports = { - // Open license ensures JS files are accessible as is - // which is important for local templates installation - license: 'MIT', - files: [ - // Components - '../../components', - ], -}; diff --git a/src/ComponentsService.js b/src/ComponentsService.js index 7a59cae..4f6d44f 100644 --- a/src/ComponentsService.js +++ b/src/ComponentsService.js @@ -2,7 +2,7 @@ const { resolve } = require('path'); const { isEmpty, path } = require('ramda'); -const { Graph, alg } = require('graphlib'); +const { Graph, alg } = require('@dagrejs/graphlib'); const traverse = require('traverse'); const pLimit = require('p-limit'); const ServerlessError = require('./serverless-error'); diff --git a/src/Context.js b/src/Context.js index e59a148..4f8ec43 100644 --- a/src/Context.js +++ b/src/Context.js @@ -1,7 +1,6 @@ 'use strict'; const path = require('path'); -const prettyoutput = require('prettyoutput'); const isPlainObject = require('type/plain-object/is'); const utils = require('./utils'); const packageJson = require('../package.json'); @@ -9,6 +8,7 @@ const LocalStateStorage = require('./state/LocalStateStorage'); const getS3StateStorageFromConfig = require('./state/get-s3-state-storage-from-config'); const Output = require('./cli/Output'); const readline = require('readline'); +const formatOutput = require('./cli/format-output'); const Progresses = require('./cli/Progresses'); const colors = require('./cli/colors'); const ServerlessError = require('./serverless-error'); @@ -72,22 +72,11 @@ class Context { } renderOutputs(outputs) { - if (typeof outputs !== 'object' || Object.keys(outputs).length === 0) { + if (!outputs || typeof outputs !== 'object' || Object.keys(outputs).length === 0) { return; } - this.output.writeText( - `\n${prettyoutput(outputs, { - alignKeyValues: false, - colors: { - keys: 'gray', - dash: 'gray', - number: 'white', - true: 'white', - false: 'white', - }, - })}`.trimEnd() - ); + this.output.writeText(formatOutput(outputs).trimEnd()); } logVerbose(message) { diff --git a/src/cli/Progresses.js b/src/cli/Progresses.js index e1baa8a..27bf603 100644 --- a/src/cli/Progresses.js +++ b/src/cli/Progresses.js @@ -261,13 +261,21 @@ class Progresses { } bindSigint() { - process.on('SIGINT', () => { + Progresses.lastBoundInstance = this; + if (Progresses.sigintHandler) { + return; + } + + Progresses.sigintHandler = () => { + const boundInstance = Progresses.lastBoundInstance; cliCursor.show(); - if (this.output.interactiveStderr) { - this.output.interactiveStderr.moveCursor(0, this.lineCount); + if (boundInstance && boundInstance.output.interactiveStderr) { + boundInstance.output.interactiveStderr.moveCursor(0, boundInstance.lineCount); } process.exit(0); - }); + }; + + process.on('SIGINT', Progresses.sigintHandler); } ellipsis(text) { diff --git a/src/cli/colors.js b/src/cli/colors.js index dc03a3a..6b19c09 100644 --- a/src/cli/colors.js +++ b/src/cli/colors.js @@ -10,4 +10,5 @@ module.exports = { gray: colorSupportLevel > 2 ? chalk.rgb(140, 141, 145) : chalk.gray, red: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright, warning: chalk.rgb(255, 165, 0), + white: chalk.white, }; diff --git a/src/cli/format-output.js b/src/cli/format-output.js new file mode 100644 index 0000000..5c7e1c6 --- /dev/null +++ b/src/cli/format-output.js @@ -0,0 +1,153 @@ +'use strict'; + +const colors = require('./colors'); + +const INDENTATION = ' '; +const MAX_DEPTH = 3; + +const isSingleLineString = (value) => typeof value === 'string' && !value.includes('\n'); + +const isScalarLike = (value) => + value === null || + value === undefined || + typeof value === 'boolean' || + typeof value === 'number' || + isSingleLineString(value); + +const renderScalar = (value) => { + if (value === null) { + return colors.gray('null'); + } + if (value === undefined) { + return colors.gray('undefined'); + } + if (typeof value === 'boolean' || typeof value === 'number') { + return colors.white(String(value)); + } + return String(value); +}; + +const renderMultilineString = (value, indentation) => { + const nestedIndentation = `${indentation}${INDENTATION}`; + const lines = value.split('\n').map((line) => `${nestedIndentation}${line}`); + return `${indentation}"""\n${lines.join('\n')}\n${indentation}"""\n`; +}; + +function renderArray(value, depth, indentation, renderNestedValue) { + if (value.length === 0) { + return `${indentation}(empty array)\n`; + } + + let output = ''; + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + const prefix = colors.gray(`${indentation}- `); + + if (Array.isArray(item)) { + if (item.length === 0) { + output += `${prefix}(empty array)\n`; + } else if (depth + 1 > MAX_DEPTH) { + output += `${prefix}(max depth reached)\n`; + } else { + output += `${prefix}\n${renderNestedValue( + item, + depth + 1, + `${indentation}${INDENTATION}` + )}`; + } + continue; + } + + if (typeof item === 'string' && !isSingleLineString(item)) { + if (depth + 1 > MAX_DEPTH) { + output += `${prefix}(max depth reached)\n`; + } else { + output += `${prefix}\n${renderMultilineString(item, `${indentation}${INDENTATION}`)}`; + } + continue; + } + + if (isScalarLike(item)) { + output += `${prefix}${renderScalar(item)}\n`; + continue; + } + + if (depth + 1 > MAX_DEPTH) { + output += `${prefix}(max depth reached)\n`; + continue; + } + + output += `${prefix}\n${renderNestedValue(item, depth + 1, `${indentation}${INDENTATION}`)}`; + } + + return output; +} + +function renderObject(value, depth, indentation, renderNestedValue) { + let output = ''; + + for (const key of Object.keys(value)) { + const childValue = value[key]; + const prefix = colors.gray(`${indentation}${key}: `); + + if (Array.isArray(childValue)) { + if (childValue.length === 0) { + output += `${prefix}(empty array)\n`; + } else if (depth + 1 > MAX_DEPTH) { + output += `${prefix}(max depth reached)\n`; + } else { + output += `${prefix}\n${renderNestedValue( + childValue, + depth + 1, + `${indentation}${INDENTATION}` + )}`; + } + continue; + } + + if (typeof childValue === 'string' && !isSingleLineString(childValue)) { + if (depth + 1 > MAX_DEPTH) { + output += `${prefix}(max depth reached)\n`; + } else { + output += `${prefix}\n${renderMultilineString(childValue, `${indentation}${INDENTATION}`)}`; + } + continue; + } + + if (isScalarLike(childValue)) { + output += `${prefix}${renderScalar(childValue)}\n`; + continue; + } + + if (depth + 1 > MAX_DEPTH) { + output += `${prefix}(max depth reached)\n`; + continue; + } + + output += `${prefix}\n${renderNestedValue( + childValue, + depth + 1, + `${indentation}${INDENTATION}` + )}`; + } + + return output; +} + +function renderValue(value, depth = 0, indentation = '') { + if (Array.isArray(value)) { + return renderArray(value, depth, indentation, renderValue); + } + + if (typeof value === 'string' && !isSingleLineString(value)) { + return renderMultilineString(value, indentation); + } + + if (isScalarLike(value)) { + return `${indentation}${renderScalar(value)}\n`; + } + + return renderObject(value, depth, indentation, renderValue); +} + +module.exports = (value) => renderValue(value); diff --git a/src/cli/is-supported-node-version.js b/src/cli/is-supported-node-version.js new file mode 100644 index 0000000..f3e08a6 --- /dev/null +++ b/src/cli/is-supported-node-version.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = (version) => { + const major = Number(version.split('.')[0].slice(1)); + const minor = Number(version.split('.')[1]); + + return major > 20 || (major === 20 && minor >= 0); +}; diff --git a/src/index.js b/src/index.js index 808a69b..96b8278 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ 'use strict'; // Setup log writing -require('@serverless/utils/log-reporters/node'); +require('./utils/serverless-utils/log-reporters/node'); const args = require('minimist')(process.argv.slice(2)); const renderHelp = require('./render-help'); diff --git a/src/state/get-s3-state-storage-from-config.js b/src/state/get-s3-state-storage-from-config.js index f351474..0ba7897 100644 --- a/src/state/get-s3-state-storage-from-config.js +++ b/src/state/get-s3-state-storage-from-config.js @@ -1,8 +1,9 @@ 'use strict'; -const { getCredentialProvider } = require('@serverless-components/utils-aws'); +const { getAwsClientConfig } = require('../utils/aws'); const S3StateStorage = require('./S3StateStorage'); +const getConfiguredStateBucketName = require('./utils/get-configured-state-bucket-name'); const getStateBucketName = require('./utils/get-state-bucket-name'); const getStateBucketRegion = require('./utils/get-state-bucket-region'); @@ -17,14 +18,22 @@ const getS3StateStorageFromConfig = async (stateConfiguration, context) => { context.stage }/state.json`; - // We want to resolve region from S3 only for externally provided S3 buckets to avoid extra SDK call - const region = stateConfiguration.externalBucket - ? await getStateBucketRegion(bucketName) + const configuredBucketName = getConfiguredStateBucketName(stateConfiguration); + const region = configuredBucketName + ? await getStateBucketRegion(bucketName, stateConfiguration) : 'us-east-1'; - const credentialProvider = getCredentialProvider({ profile: stateConfiguration.profile, region }); + const awsClientConfig = getAwsClientConfig({ + profile: stateConfiguration.profile, + region, + }); - return new S3StateStorage({ bucketName, stateKey, region, credentials: credentialProvider }); + return new S3StateStorage({ + bucketName, + stateKey, + region, + credentials: awsClientConfig.credentials, + }); }; module.exports = getS3StateStorageFromConfig; diff --git a/src/state/utils/get-configured-state-bucket-name.js b/src/state/utils/get-configured-state-bucket-name.js new file mode 100644 index 0000000..e956201 --- /dev/null +++ b/src/state/utils/get-configured-state-bucket-name.js @@ -0,0 +1,16 @@ +'use strict'; + +const ServerlessError = require('../../serverless-error'); + +module.exports = (stateConfiguration = {}) => { + const { existingBucket, externalBucket } = stateConfiguration; + + if (existingBucket && externalBucket && existingBucket !== externalBucket) { + throw new ServerlessError( + 'State configuration cannot define both "existingBucket" and "externalBucket" with different values.', + 'INVALID_STATE_CONFIGURATION' + ); + } + + return existingBucket || externalBucket || null; +}; diff --git a/src/state/utils/get-state-bucket-name.js b/src/state/utils/get-state-bucket-name.js index 7e5d1a9..a84c6f6 100644 --- a/src/state/utils/get-state-bucket-name.js +++ b/src/state/utils/get-state-bucket-name.js @@ -1,22 +1,28 @@ 'use strict'; const crypto = require('crypto'); -const AWS = require('@aws-sdk/client-cloudformation'); +const { CloudFormation } = require('@aws-sdk/client-cloudformation'); +const { getAwsClientConfig } = require('../../utils/aws'); const { sleep } = require('../../utils'); +const getConfiguredStateBucketName = require('./get-configured-state-bucket-name'); const remoteStateCloudFormationTemplate = require('./remote-state-cloudformation-template.json'); const ServerlessError = require('../../serverless-error'); const COMPOSE_REMOTE_STATE_STACK_NAME = 'serverless-compose-state'; -// TODO: INJECT RESOLVED AWS CREDENTIALS -const getCloudFormationClient = () => { +const getCloudFormationClient = (stateConfiguration = {}) => { // We are enforcing us-east-1 as the intention (that might change in the future if we find a good reason) // is to only create one such bucket across all regions in a single AWS Account - return new AWS.CloudFormation({ region: 'us-east-1' }); + return new CloudFormation( + getAwsClientConfig({ + profile: stateConfiguration.profile, + region: 'us-east-1', + }) + ); }; -const monitorStackCreation = async (stackName, context) => { - const client = getCloudFormationClient(); +const monitorStackCreation = async (stackName, context, stateConfiguration) => { + const client = getCloudFormationClient(stateConfiguration); const describeStacksResponse = await client.describeStacks({ StackName: stackName }); const status = describeStacksResponse.Stacks[0].StackStatus; @@ -24,7 +30,7 @@ const monitorStackCreation = async (stackName, context) => { // TODO: REMOVE WHEN REPLACED WITH PROGRESS context.logVerbose('Stack deployment in progress'); await sleep(2000); - return await monitorStackCreation(stackName, context); + return await monitorStackCreation(stackName, context, stateConfiguration); } if (status === 'CREATE_COMPLETE') { @@ -39,10 +45,11 @@ const monitorStackCreation = async (stackName, context) => { }; /** + * @param {Record} stateConfiguration * @param {import('../../Context')} context */ -const ensureRemoteStateBucketStackExists = async (context) => { - const client = getCloudFormationClient(); +const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) => { + const client = getCloudFormationClient(stateConfiguration); const templateBody = JSON.stringify(remoteStateCloudFormationTemplate); // TODO: REPLACE WITH PROGRESS @@ -60,13 +67,13 @@ const ensureRemoteStateBucketStackExists = async (context) => { ], }); - await monitorStackCreation(COMPOSE_REMOTE_STATE_STACK_NAME, context); + await monitorStackCreation(COMPOSE_REMOTE_STATE_STACK_NAME, context, stateConfiguration); context.output.log('S3 bucket for remote state created successfully'); return bucketName; }; -const getStateBucketNameFromCF = async () => { - const client = getCloudFormationClient(); +const getStateBucketNameFromCF = async (stateConfiguration) => { + const client = getCloudFormationClient(stateConfiguration); const logicalResourceId = 'ServerlessComposeRemoteStateBucket'; const result = await client.describeStackResource({ StackName: COMPOSE_REMOTE_STATE_STACK_NAME, @@ -90,27 +97,27 @@ const getStateBucketNameFromCF = async () => { */ const getStateBucketName = async (stateConfiguration, context) => { // 1. Check from config - if (stateConfiguration && stateConfiguration.existingBucket) { - return stateConfiguration.existingBucket; + const configuredBucketName = getConfiguredStateBucketName(stateConfiguration); + if (configuredBucketName) { + return configuredBucketName; } // 2. Check from remote try { - return await getStateBucketNameFromCF(); + return await getStateBucketNameFromCF(stateConfiguration); } catch (e) { - // If message incldues 'does not exist', we need move forward and create the stack first + // If message includes 'does not exist', we need to move forward and create the stack first if (!(e.Code === 'ValidationError' && e.message.includes('does not exist'))) { throw new ServerlessError( `Could not retrieve S3 state bucket: ${e.message}`, 'CANNOT_RETRIEVE_REMOTE_STATE_S3_BUCKET' ); } - // PASS } // 3. If stack does not exist, ensure it exists try { - return await ensureRemoteStateBucketStackExists(context); + return await ensureRemoteStateBucketStackExists(context, stateConfiguration); } catch (e) { if (e instanceof ServerlessError) { throw e; diff --git a/src/state/utils/get-state-bucket-region.js b/src/state/utils/get-state-bucket-region.js index 92534d6..edc15b7 100644 --- a/src/state/utils/get-state-bucket-region.js +++ b/src/state/utils/get-state-bucket-region.js @@ -1,11 +1,16 @@ 'use strict'; const { S3 } = require('@aws-sdk/client-s3'); +const { getAwsClientConfig } = require('../../utils/aws'); const ServerlessError = require('../../serverless-error'); -const getStateBucketRegion = async (bucketName) => { - // TODO: INJECT RESOLVED AWS CREDENTIALS - const client = new S3(); +const getStateBucketRegion = async (bucketName, stateConfiguration = {}) => { + const client = new S3( + getAwsClientConfig({ + profile: stateConfiguration.profile, + region: 'us-east-1', + }) + ); let result; try { @@ -31,8 +36,13 @@ const getStateBucketRegion = async (bucketName) => { ); } - // Buckets in `us-east-1` have `LocationConstraint` empty - return result.LocationConstraint || 'us-east-1'; + if (!result.LocationConstraint) { + return 'us-east-1'; + } + if (result.LocationConstraint === 'EU') { + return 'eu-west-1'; + } + return result.LocationConstraint; }; module.exports = getStateBucketRegion; diff --git a/src/utils/aws/get-client-config.js b/src/utils/aws/get-client-config.js new file mode 100644 index 0000000..b3ea4e9 --- /dev/null +++ b/src/utils/aws/get-client-config.js @@ -0,0 +1,8 @@ +'use strict'; + +const getCredentialProvider = require('./get-credential-provider'); + +module.exports = ({ profile, region } = {}) => ({ + region, + credentials: getCredentialProvider({ profile, region }), +}); diff --git a/src/utils/aws/get-credential-provider.js b/src/utils/aws/get-credential-provider.js new file mode 100644 index 0000000..814d218 --- /dev/null +++ b/src/utils/aws/get-credential-provider.js @@ -0,0 +1,14 @@ +'use strict'; + +const fromNodeProviderChain = require('./provider-chain/from-node-provider-chain'); + +module.exports = ({ profile, region } = {}) => { + if (process.env.AWS_DEFAULT_PROFILE && !process.env.AWS_PROFILE) { + process.env.AWS_PROFILE = process.env.AWS_DEFAULT_PROFILE; + } + + return fromNodeProviderChain({ + profile, + clientConfig: { region }, + }); +}; diff --git a/src/utils/aws/index.js b/src/utils/aws/index.js new file mode 100644 index 0000000..3c43a8b --- /dev/null +++ b/src/utils/aws/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const getAwsClientConfig = require('./get-client-config'); +const getCredentialProvider = require('./get-credential-provider'); + +module.exports = { + getAwsClientConfig, + getCredentialProvider, +}; diff --git a/src/utils/aws/provider-chain/default-provider.js b/src/utils/aws/provider-chain/default-provider.js new file mode 100644 index 0000000..bac984e --- /dev/null +++ b/src/utils/aws/provider-chain/default-provider.js @@ -0,0 +1,36 @@ +'use strict'; + +// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3 +// credential-provider internals. Kept local so compose owns only the small +// provider chain it actually uses. + +const { fromEnv } = require('@aws-sdk/credential-provider-env'); +const { fromIni } = require('@aws-sdk/credential-provider-ini'); +const { fromProcess } = require('@aws-sdk/credential-provider-process'); +const { fromSSO } = require('@aws-sdk/credential-provider-sso'); +const { fromTokenFile } = require('@aws-sdk/credential-provider-web-identity'); +const { chain, CredentialsProviderError, memoize } = require('@aws-sdk/property-provider'); + +const remoteProvider = require('./remote-provider'); + +function defaultProvider(init) { + return memoize( + chain( + ...(init.profile ? [] : [fromEnv()]), + fromSSO(init), + fromIni(init), + fromProcess(init), + fromTokenFile(init), + remoteProvider(init), + async () => { + throw new CredentialsProviderError('Could not load credentials from any providers', false); + } + ), + (credentials) => + credentials.expiration !== undefined && + credentials.expiration.getTime() - Date.now() < 300000, + (credentials) => credentials.expiration !== undefined + ); +} + +module.exports = defaultProvider; diff --git a/src/utils/aws/provider-chain/from-node-provider-chain.js b/src/utils/aws/provider-chain/from-node-provider-chain.js new file mode 100644 index 0000000..7f140ea --- /dev/null +++ b/src/utils/aws/provider-chain/from-node-provider-chain.js @@ -0,0 +1,23 @@ +'use strict'; + +// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3 +// credential-provider internals. Kept local so compose owns only the small +// provider chain it actually uses. + +const { + getDefaultRoleAssumer, + getDefaultRoleAssumerWithWebIdentity, +} = require('@aws-sdk/client-sts'); + +const defaultProvider = require('./default-provider'); + +function fromNodeProviderChain(init) { + return defaultProvider({ + ...init, + roleAssumer: init.roleAssumer || getDefaultRoleAssumer(init.clientConfig), + roleAssumerWithWebIdentity: + init.roleAssumerWithWebIdentity || getDefaultRoleAssumerWithWebIdentity(init.clientConfig), + }); +} + +module.exports = fromNodeProviderChain; diff --git a/src/utils/aws/provider-chain/remote-provider.js b/src/utils/aws/provider-chain/remote-provider.js new file mode 100644 index 0000000..89ddc04 --- /dev/null +++ b/src/utils/aws/provider-chain/remote-provider.js @@ -0,0 +1,32 @@ +'use strict'; + +// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3 +// credential-provider internals. Kept local so compose owns only the small +// provider chain it actually uses. + +const { CredentialsProviderError } = require('@aws-sdk/property-provider'); + +const { + ENV_CMDS_FULL_URI, + ENV_CMDS_RELATIVE_URI, + fromContainerMetadata, + fromInstanceMetadata, +} = require('@aws-sdk/credential-provider-imds'); + +const ENV_IMDS_DISABLED = 'AWS_EC2_METADATA_DISABLED'; + +function remoteProvider(init) { + if (process.env[ENV_CMDS_RELATIVE_URI] || process.env[ENV_CMDS_FULL_URI]) { + return fromContainerMetadata(init); + } + + if (process.env[ENV_IMDS_DISABLED]) { + return async () => { + throw new CredentialsProviderError('EC2 Instance Metadata Service access disabled'); + }; + } + + return fromInstanceMetadata(init); +} + +module.exports = remoteProvider; diff --git a/src/utils/serverless-utils/README.md b/src/utils/serverless-utils/README.md new file mode 100644 index 0000000..7c25e20 --- /dev/null +++ b/src/utils/serverless-utils/README.md @@ -0,0 +1,38 @@ +Curated `@serverless/utils` vendor for compose + +This subtree contains the small logging/reporting slice of `@serverless/utils` +that compose still needs at process startup. + +It is internal only. +Compose does not alias any `@serverless/utils/*` import paths to this +directory. + +Loaded components should use the compose `ComponentContext` API for logging and +progress. If a component wants to use `@serverless/utils/*`, it must declare +that dependency itself. + +Included here: + +- `log.js` +- `log-reporters/node.js` +- `lib/log/*` +- `lib/log-reporters/node/*` + +Explicitly excluded here: + +- config helpers +- download helpers +- schema helpers +- inquirer helpers +- compatibility shims and aliases + +Source package: + +- `@serverless/utils` +- upstream version tracked in `policy.js` + +When updating this subtree: + +1. Update `policy.js`. +2. Update the structural allowlist test. +3. Add or adjust unit tests for changed behavior. diff --git a/src/utils/serverless-utils/lib/log-reporters/node/log-reporter.js b/src/utils/serverless-utils/lib/log-reporters/node/log-reporter.js new file mode 100644 index 0000000..007f782 --- /dev/null +++ b/src/utils/serverless-utils/lib/log-reporters/node/log-reporter.js @@ -0,0 +1,72 @@ +'use strict'; + +const NodeLogReporter = require('log-node/lib/writer'); +const logLevels = require('log/levels'); +const logEmitter = require('log/lib/emitter'); +const colorsSupportLevel = require('supports-color').stderr.level || 0; +const style = require('./style'); + +const WARNING_LEVEL_INDEX = logLevels.indexOf('warning'); +const ERROR_LEVEL_INDEX = logLevels.indexOf('error'); + +class ServerlessLogReporter extends NodeLogReporter { + constructor({ logLevelIndex, debugNamespaces }) { + if (debugNamespaces) { + debugNamespaces = debugNamespaces + .split(',') + .filter(Boolean) + .map((namespace) => `serverless:${namespace}`) + .join(','); + } + super({ + env: { LOG_LEVEL: logLevels[logLevelIndex], LOG_DEBUG: debugNamespaces }, + defaultNamespace: 'serverless', + }); + + logEmitter.on('init', ({ logger }) => { + if (logger.namespace === 'serverless:deprecation') { + logger.levelMessagePrefix = null; + } + }); + } + + isLoggerEnabled(logger) { + return logger.namespaceTokens[0] === 'serverless' && logger.isEnabled; + } + + setupLevelMessageDecorator(levelLogger) { + if (levelLogger.levelIndex === WARNING_LEVEL_INDEX) { + levelLogger.messageContentDecorator = style.warning; + } + } + + resolveMessageContent(event) { + super.resolveMessageContent(event); + if (event.logger.levelIndex !== ERROR_LEVEL_INDEX) { + return; + } + const [firstLine, ...otherLines] = event.messageContent.split('\n'); + event.messageContent = `${style.error(firstLine)}${ + otherLines.length ? `\n ${style.aside(otherLines.join('\n '))}` : '' + }`; + } + + resolveMessageTimestamp() {} +} + +ServerlessLogReporter.levelPrefixes = { + error: style.error(process.platform !== 'win32' && colorsSupportLevel >= 2 ? '✖' : '×'), + warning: style.warning('Warning:'), +}; + +ServerlessLogReporter.resolveNamespaceMessagePrefix = function (logger) { + if (logger.level !== 'debug') { + return null; + } + if (logger.namespaceTokens.length < 2) { + return null; + } + return `${logger.namespaceTokens.slice(1).join(':')}:`; +}; + +module.exports = (config) => new ServerlessLogReporter(config); diff --git a/src/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js b/src/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js new file mode 100644 index 0000000..0529296 --- /dev/null +++ b/src/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js @@ -0,0 +1,68 @@ +'use strict'; + +const getCliProgressFooter = require('cli-progress-footer'); +const { emitter } = require('../../log/get-progress-reporter'); +const { progress, log } = require('../../../log'); +const joinTextTokens = require('../../log/join-text-tokens'); +const style = require('./style'); + +module.exports = ({ logLevelIndex }) => { + const cliProgressFooter = getCliProgressFooter(); + cliProgressFooter.shouldAddProgressAnimationPrefix = true; + cliProgressFooter.progressAnimationPrefixFrames = + cliProgressFooter.progressAnimationPrefixFrames.map((frame) => style.noticeSymbol(frame)); + + let mainProgressTextContent; + let mainProgressIntervalId; + let mainProgressStartTime; + let lastMainEventText; + + let isClosed = false; + progress.clear = () => { + isClosed = true; + clearInterval(mainProgressIntervalId); + cliProgressFooter.updateProgress(); + }; + + const ongoingSubProgress = new Map(); + const repaint = () => { + if (isClosed) { + return; + } + const progressItems = []; + if (mainProgressStartTime) { + progressItems.push( + `${mainProgressTextContent} ${style.aside( + `(${Math.floor((Date.now() - mainProgressStartTime) / 1000)}s)` + )}` + ); + } + progressItems.push(...ongoingSubProgress.values()); + cliProgressFooter.updateProgress(progressItems); + }; + + emitter.on('update', ({ namespace, name, levelIndex, textTokens, options }) => { + if (levelIndex > logLevelIndex) { + return; + } + const textContent = joinTextTokens([textTokens]).slice(0, -1); + if (namespace === 'serverless' && name === 'main') { + if (options && options.isMainEvent && lastMainEventText !== textContent) { + lastMainEventText = textContent; + log.info(textContent); + } + mainProgressTextContent = textContent; + if (!mainProgressStartTime) { + mainProgressStartTime = Date.now(); + mainProgressIntervalId = setInterval(repaint, 200); + } + } else { + ongoingSubProgress.set(`${namespace}:${name}`, textContent); + } + repaint(); + }); + emitter.on('remove', ({ namespace, name }) => { + ongoingSubProgress.delete(`${namespace}:${name}`); + repaint(); + }); +}; diff --git a/src/utils/serverless-utils/lib/log-reporters/node/style.js b/src/utils/serverless-utils/lib/log-reporters/node/style.js new file mode 100644 index 0000000..067809e --- /dev/null +++ b/src/utils/serverless-utils/lib/log-reporters/node/style.js @@ -0,0 +1,40 @@ +'use strict'; + +const d = require('d'); +const autoBind = require('d/auto-bind'); +const identity = require('ext/function/identity'); +const chalk = require('chalk'); +const supportsColor = require('supports-color'); +const { style, log } = require('../../../log'); +const joinTextTokens = require('../../log/join-text-tokens'); + +const colorSupportLevel = supportsColor.stderr ? supportsColor.stderr.level : 0; + +const cliStyle = { + aside: colorSupportLevel > 2 ? chalk.rgb(140, 141, 145) : chalk.gray, + error: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright, + link: identity, + linkStrong: chalk.underline, + noticeSymbol: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright, + strong: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright, + title: chalk.underline, + warning: chalk.rgb(255, 165, 0), +}; + +for (const key of Object.keys(style)) { + const decorator = cliStyle[key]; + if (!decorator) { + continue; + } + module.exports[key] = style[key] = (text, ...textTokens) => + decorator(joinTextTokens([text, ...textTokens]).slice(0, -1)); +} + +Object.defineProperties( + log, + autoBind({ + success: d(function (text, ...messageTokens) { + return this.notice(`${cliStyle.noticeSymbol('✔')} ${text}`, ...messageTokens); + }), + }) +); diff --git a/src/utils/serverless-utils/lib/log/get-output-reporter.js b/src/utils/serverless-utils/lib/log/get-output-reporter.js new file mode 100644 index 0000000..068578d --- /dev/null +++ b/src/utils/serverless-utils/lib/log/get-output-reporter.js @@ -0,0 +1,30 @@ +'use strict'; + +const uniGlobal = require('uni-global')('serverless/serverless/202110'); +const memoizee = require('memoizee'); + +const outputEmitter = (() => { + if (!uniGlobal.outputEmitter) uniGlobal.outputEmitter = require('event-emitter')(); + return uniGlobal.outputEmitter; +})(); + +module.exports = memoizee( + (namespace) => { + return { + get: memoizee( + (mode) => + (text, ...textTokens) => { + outputEmitter.emit('write', { + namespace, + mode, + textTokens: [text, ...textTokens], + }); + }, + { primitive: true } + ), + }; + }, + { primitive: true } +); + +module.exports.emitter = outputEmitter; diff --git a/src/utils/serverless-utils/lib/log/get-progress-reporter.js b/src/utils/serverless-utils/lib/log/get-progress-reporter.js new file mode 100644 index 0000000..2058f50 --- /dev/null +++ b/src/utils/serverless-utils/lib/log/get-progress-reporter.js @@ -0,0 +1,73 @@ +'use strict'; + +const ensureString = require('type/string/ensure'); +const isObject = require('type/object/is'); +const createRandomString = require('ext/string/random'); +const uniGlobal = require('uni-global')('serverless/serverless/202110'); +const memoizee = require('memoizee'); +const logLevels = require('log/levels'); + +const progressEmitter = (() => { + if (!uniGlobal.progressEmitter) uniGlobal.progressEmitter = require('event-emitter')(); + return uniGlobal.progressEmitter; +})(); + +module.exports = memoizee( + (namespace) => { + return { + get: memoizee( + (name) => { + name = ensureString(name, { name: 'name' }); + const progress = { + namespace, + name, + remove: () => { + progressEmitter.emit('remove', { namespace, name }); + }, + }; + const levelsMeta = [ + { levelName: 'info', levelIndex: logLevels.indexOf('info') }, + { levelName: 'notice', levelIndex: logLevels.indexOf('notice') }, + ]; + for (const { levelName, levelIndex } of levelsMeta) { + progress[levelName] = (textTokens, options = null) => { + progressEmitter.emit('update', { + namespace, + name, + level: levelName, + levelIndex, + textTokens, + options, + }); + }; + } + progress.update = progress.notice; + return progress; + }, + { primitive: true } + ), + create(options = {}) { + if (!isObject(options)) options = {}; + const message = ensureString(options.message, { + isOptional: true, + name: 'options.message', + }); + const name = ensureString(options.name, { + isOptional: true, + name: 'options.name', + }); + if (name && this.get._has(name)) { + throw Object.assign(new Error(`Progress named "${name}" already exists`), { + code: 'PROGRESS_NAME_TAKEN', + }); + } + const progress = this.get(name || `unnamed-${createRandomString({ isUnique: true })}`); + if (message != null) progress.notice(message); + return progress; + }, + }; + }, + { primitive: true } +); + +module.exports.emitter = progressEmitter; diff --git a/src/utils/serverless-utils/lib/log/join-text-tokens.js b/src/utils/serverless-utils/lib/log/join-text-tokens.js new file mode 100644 index 0000000..437c546 --- /dev/null +++ b/src/utils/serverless-utils/lib/log/join-text-tokens.js @@ -0,0 +1,22 @@ +// Dedicated to join text tokens as passed to `writeText` and `progress.update` + +'use strict'; + +const ensureString = require('type/string/ensure'); + +const flattenDeep = (items) => { + const result = []; + for (const item of items) { + if (Array.isArray(item)) { + result.push(...flattenDeep(item)); + } else { + result.push(item); + } + } + return result; +}; + +module.exports = (textTokens) => + `${flattenDeep(textTokens) + .map((textToken) => ensureString(textToken, { isOptional: true, name: 'textToken' }) || '') + .join('\n')}\n`; diff --git a/src/utils/serverless-utils/log-reporters/node.js b/src/utils/serverless-utils/log-reporters/node.js new file mode 100644 index 0000000..e58e5c3 --- /dev/null +++ b/src/utils/serverless-utils/log-reporters/node.js @@ -0,0 +1,50 @@ +'use strict'; + +const uniGlobal = require('uni-global')('serverless/serverless/202110'); + +if (uniGlobal.logLevelIndex != null) { + return; +} + +if (process.env.SLS_LOG_LEVEL !== 'debug' && process.argv.includes('--verbose')) { + process.env.SLS_LOG_LEVEL = 'info'; +} + +process.argv.some((flag, index) => { + const namespace = (() => { + if (flag === '--debug') return process.argv[index + 1]; + if (flag.startsWith('--debug=')) return flag.slice('--debug='.length); + return null; + })(); + if (!namespace) return false; + if (namespace === '*') process.env.SLS_LOG_LEVEL = 'debug'; + else process.env.SLS_LOG_DEBUG = namespace; + return true; +}); + +const logReporter = require('../lib/log-reporters/node/log-reporter'); +const { emitter: outputEmitter } = require('../lib/log/get-output-reporter'); +const joinTextTokens = require('../lib/log/join-text-tokens'); +const logLevels = require('log/levels'); + +const logLevelIndex = logLevels.includes(process.env.SLS_LOG_LEVEL) + ? logLevels.indexOf(process.env.SLS_LOG_LEVEL) + : logLevels.indexOf('notice'); + +const isInteractive = + (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) || + process.env.SLS_INTERACTIVE_SETUP_ENABLE; + +require('../lib/log-reporters/node/style'); + +logReporter({ logLevelIndex, debugNamespaces: process.env.SLS_LOG_DEBUG }); +uniGlobal.logLevelIndex = logLevelIndex; + +outputEmitter.on('write', ({ mode, textTokens }) => { + if (mode === 'text') process.stdout.write(joinTextTokens(textTokens)); +}); + +uniGlobal.logIsInteractive = isInteractive; +if (isInteractive) { + require('../lib/log-reporters/node/progress-reporter')({ logLevelIndex }); +} diff --git a/src/utils/serverless-utils/log.js b/src/utils/serverless-utils/log.js new file mode 100644 index 0000000..7ca5867 --- /dev/null +++ b/src/utils/serverless-utils/log.js @@ -0,0 +1,83 @@ +'use strict'; + +const ensureString = require('type/string/ensure'); +const d = require('d'); +const memoizee = require('memoizee'); +const logLevels = require('log/levels'); +const uniGlobal = require('uni-global')('serverless/serverless/202110'); +const getOutputReporter = require('./lib/log/get-output-reporter'); +const getProgressReporter = require('./lib/log/get-progress-reporter'); + +const log = (() => { + if (!uniGlobal.log) uniGlobal.log = require('log').get('serverless').notice; + return uniGlobal.log; +})(); + +module.exports.log = log; + +if (!log.verbose) { + Object.defineProperties(log, { + success: d.gs(function () { + return this.notice; + }), + skip: d.gs(function () { + return this.notice; + }), + }); + + Object.defineProperties(log, { + verbose: d.gs(function () { + return this.info; + }), + }); +} + +const defaultLogLevelIndex = logLevels.indexOf('notice'); +Object.defineProperties(module.exports, { + logLevelIndex: d.gs(() => { + return uniGlobal.logLevelIndex == null ? defaultLogLevelIndex : uniGlobal.logLevelIndex; + }), + isVerboseMode: d.gs(() => module.exports.logLevelIndex > defaultLogLevelIndex), + isInteractive: d.gs(() => { + return uniGlobal.logIsInteractive == null ? false : uniGlobal.logIsInteractive; + }), +}); + +module.exports.writeText = getOutputReporter('serverless').get('text'); + +module.exports.progress = getProgressReporter('serverless'); +module.exports.progress.clear = () => {}; + +module.exports.getPluginWriters = memoizee( + (pluginName) => { + pluginName = ensureString(pluginName, { name: 'pluginName' }); + const pluginLog = log.get('plugin').get(pluginName.toLowerCase().replace(/[^a-z0-9-]/g, '-')); + pluginLog.pluginName = pluginName; + return { + log: pluginLog.notice, + writeText: getOutputReporter(`serverless:plugin:${pluginName}`).get('text'), + progress: getProgressReporter(`serverless:plugin:${pluginName}`), + }; + }, + { primitive: true } +); + +const style = { + aside: (text, ...textTokens) => [text, ...textTokens], + error: (text, ...textTokens) => [text, ...textTokens], + link: (text, ...textTokens) => [text, ...textTokens], + linkStrong: (text, ...textTokens) => [text, ...textTokens], + noticeSymbol: (text, ...textTokens) => [text, ...textTokens], + strong: (text, ...textTokens) => [text, ...textTokens], + title: (text, ...textTokens) => [text, ...textTokens], + warning: (text, ...textTokens) => [text, ...textTokens], +}; + +if (uniGlobal.logStyle) { + module.exports.style = uniGlobal.logStyle; + for (const key of Object.keys(style)) { + if (!uniGlobal.logStyle[key]) uniGlobal.logStyle[key] = style[key]; + } +} else { + module.exports.style = uniGlobal.logStyle = style; +} diff --git a/src/utils/serverless-utils/policy.js b/src/utils/serverless-utils/policy.js new file mode 100644 index 0000000..345e056 --- /dev/null +++ b/src/utils/serverless-utils/policy.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = Object.freeze({ + upstream: Object.freeze({ + packageName: '@serverless/utils', + version: '6.15.0', + }), + + vendoredPaths: Object.freeze([ + 'lib/log/get-output-reporter.js', + 'lib/log/get-progress-reporter.js', + 'lib/log/join-text-tokens.js', + 'lib/log-reporters/node/log-reporter.js', + 'lib/log-reporters/node/progress-reporter.js', + 'lib/log-reporters/node/style.js', + 'log-reporters/node.js', + 'log.js', + ]), + + maintainerPaths: Object.freeze(['README.md', 'policy.js']), +}); diff --git a/test/fixtures/local-component.js b/test/fixtures/local-component.js new file mode 100644 index 0000000..52fc36b --- /dev/null +++ b/test/fixtures/local-component.js @@ -0,0 +1,16 @@ +'use strict'; + +class LocalComponent { + constructor(id, context, inputs) { + this.id = id; + this.context = context; + this.inputs = inputs; + } + + async emitLogs() { + this.context.writeText(this.inputs.message); + this.context.logVerbose(`verbose ${this.inputs.message}`); + } +} + +module.exports = LocalComponent; diff --git a/test/lib/private/rm-tmp-dir-ignorable-error-codes.js b/test/lib/private/rm-tmp-dir-ignorable-error-codes.js new file mode 100644 index 0000000..85d8abc --- /dev/null +++ b/test/lib/private/rm-tmp-dir-ignorable-error-codes.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = new Set(['EBUSY', 'EPERM']); diff --git a/test/lib/process-tmp-dir.js b/test/lib/process-tmp-dir.js new file mode 100644 index 0000000..4574462 --- /dev/null +++ b/test/lib/process-tmp-dir.js @@ -0,0 +1,36 @@ +'use strict'; + +const { mkdirSync, realpathSync } = require('fs'); +const { removeSync } = require('fs-extra'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const rmTmpDirIgnorableErrorCodes = require('./private/rm-tmp-dir-ignorable-error-codes'); + +const systemTmpDir = realpathSync(os.tmpdir()); +const composeTmpDir = path.join(systemTmpDir, 'tmpdirs-compose'); +try { + mkdirSync(composeTmpDir); +} catch (error) { + if (error.code !== 'EEXIST') throw error; +} + +module.exports = (function self() { + const processTmpDir = path.join(composeTmpDir, crypto.randomBytes(2).toString('hex')); + try { + mkdirSync(processTmpDir); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + return self(); + } + return processTmpDir; +})(); + +process.on('exit', () => { + try { + removeSync(module.exports); + } catch (error) { + if (rmTmpDirIgnorableErrorCodes.has(error.code)) return; + throw error; + } +}); diff --git a/test/lib/setup/log.js b/test/lib/setup/log.js new file mode 100644 index 0000000..f921f04 --- /dev/null +++ b/test/lib/setup/log.js @@ -0,0 +1,63 @@ +'use strict'; + +if (!process.env.LOG_TIME) process.env.LOG_TIME = 'abs'; + +const log = require('log').get('mocha'); +const initializeLogWriter = require('log-node'); +const { runnerEmitter } = require('./patch'); + +const logWriter = initializeLogWriter(); + +const logSuiteTitle = (suite) => { + let message = '%s'; + const args = [suite.title]; + while (suite.parent) { + suite = suite.parent; + if (suite.title) { + message = `%s > ${message}`; + args.unshift(suite.title); + } + } + log.debug(message, ...args); +}; + +runnerEmitter.on('runner', (runner) => { + runner.on('suite', logSuiteTitle); + runner.on('test', logSuiteTitle); +}); + +if (process.env.LOG_LEVEL || process.env.LOG_DEBUG || process.env.DEBUG) return; + +const logEmitter = require('log/lib/emitter'); + +const logsBuffer = []; +const flushLogs = () => { + if (logsBuffer.some((event) => event.logger.namespace !== 'mocha')) { + log.notice('flushing previously gathered logs...'); + logsBuffer.pop(); + logsBuffer.forEach((event) => { + if (!event.message) logWriter.resolveMessage(event); + logWriter.writeMessage(event); + }); + } + logsBuffer.length = 0; +}; + +logEmitter.on('log', (event) => { + logsBuffer.push(event); + if (!event.message) logWriter.resolveMessageTokens(event); +}); +runnerEmitter.on('runner', (runner) => { + runner.on('suite end', (suite) => { + if (!suite.parent || !suite.parent.root) return; + + logsBuffer.length = 0; + }); + runner.on('fail', (ignore, error) => { + log.error('test fail %s', error && error.stack); + logsBuffer.pop(); + flushLogs(); + }); +}); + +module.exports.flushLogs = flushLogs; diff --git a/test/lib/setup/mock-cwd.js b/test/lib/setup/mock-cwd.js new file mode 100644 index 0000000..3050a1f --- /dev/null +++ b/test/lib/setup/mock-cwd.js @@ -0,0 +1,16 @@ +'use strict'; + +const os = require('os'); +const { runnerEmitter } = require('./patch'); + +const resetCwd = () => { + if (process.cwd() !== os.homedir()) process.chdir(os.homedir()); +}; + +runnerEmitter.on('runner', (runner) => { + resetCwd(); + runner.on('suite end', (suite) => { + if (!suite.parent || !suite.parent.root) return; + resetCwd(); + }); +}); diff --git a/test/lib/setup/mock-homedir.js b/test/lib/setup/mock-homedir.js new file mode 100644 index 0000000..9c8b2b3 --- /dev/null +++ b/test/lib/setup/mock-homedir.js @@ -0,0 +1,43 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); +const { emptyDirSync } = require('fs-extra'); +const processTmpDir = require('../process-tmp-dir'); +const { runnerEmitter } = require('./patch'); +const rmTmpDirIgnorableErrorCodes = require('../private/rm-tmp-dir-ignorable-error-codes'); + +const createTmpHomedir = () => { + const tmpHomeDir = path.join(processTmpDir, crypto.randomBytes(3).toString('hex')); + try { + fs.mkdirSync(tmpHomeDir); + } catch (error) { + if (error.code === 'EEXIST') return createTmpHomedir(); + throw error; + } + return tmpHomeDir; +}; + +const tmpHomeDir = createTmpHomedir(); + +os.homedir = () => tmpHomeDir; +if (process.env.USERPROFILE) process.env.USERPROFILE = tmpHomeDir; +if (process.env.HOME) process.env.HOME = tmpHomeDir; + +runnerEmitter.on('runner', (runner) => { + runner.on('suite end', (suite) => { + if (!suite.parent || !suite.parent.root) return; + + try { + emptyDirSync(tmpHomeDir); + } catch (error) { + if (rmTmpDirIgnorableErrorCodes.has(error.code)) return; + if (suite.tests.some((test) => test.timedOut)) return; + process.nextTick(() => { + throw error; + }); + } + }); +}); diff --git a/test/lib/setup/patch.js b/test/lib/setup/patch.js new file mode 100644 index 0000000..4bcde5a --- /dev/null +++ b/test/lib/setup/patch.js @@ -0,0 +1,25 @@ +'use strict'; + +const EventEmitter = require('events'); +const Mocha = require('mocha/lib/mocha'); + +process.on('unhandledRejection', (err) => { + process.stderr.write(`Unhandled rejection: ${err && err.stack}\n`); + throw err; +}); + +process.env.SLS_DEPRECATION_NOTIFICATION_MODE = 'error'; +process.env.SLS_TELEMETRY_DISABLED = '1'; + +const runnerEmitter = new EventEmitter(); +const mochaRun = Mocha.prototype.run; + +Mocha.prototype.run = function (...args) { + const runner = mochaRun.apply(this, args); + if (runner && runner.constructor && runner.constructor.name === 'Runner') { + runnerEmitter.emit('runner', runner); + } + return runner; +}; + +module.exports = { runnerEmitter }; diff --git a/test/lib/setup/restore-env.js b/test/lib/setup/restore-env.js new file mode 100644 index 0000000..b613170 --- /dev/null +++ b/test/lib/setup/restore-env.js @@ -0,0 +1,20 @@ +'use strict'; + +const { runnerEmitter } = require('./patch'); + +const hasOwnProperty = Object.prototype.hasOwnProperty; + +const startEnv = Object.assign(Object.create(null), process.env); +runnerEmitter.on('runner', (runner) => + runner.on('suite end', (suite) => { + if (!suite.parent || !suite.parent.root) return; + for (const key of Object.keys(process.env)) { + if (!(key in startEnv)) delete process.env[key]; + } + for (const key of Object.keys(startEnv)) { + if (!hasOwnProperty.call(process.env, key) || process.env[key] !== startEnv[key]) { + process.env[key] = startEnv[key]; + } + } + }) +); diff --git a/test/unit/bin/serverless-compose.test.js b/test/unit/bin/serverless-compose.test.js new file mode 100644 index 0000000..6236603 --- /dev/null +++ b/test/unit/bin/serverless-compose.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/bin/serverless-compose.test.js', () => { + let originalComposeCommandStartTime; + + beforeEach(() => { + originalComposeCommandStartTime = EvalError.$composeCommandStartTime; + }); + + afterEach(() => { + if (originalComposeCommandStartTime === undefined) { + delete EvalError.$composeCommandStartTime; + } else { + EvalError.$composeCommandStartTime = originalComposeCommandStartTime; + } + sinon.restore(); + }); + + const loadBin = (stubs) => { + delete require.cache[require.resolve('../../../bin/serverless-compose')]; + proxyquire.noCallThru().load('../../../bin/serverless-compose', stubs); + }; + + it('exits before loading the runtime on unsupported Node versions', () => { + const isSupportedNodeVersion = sinon.stub().returns(false); + const runComponents = sinon.stub(); + const stderrWrite = sinon.stub(process.stderr, 'write'); + const processExitError = new Error('process.exit'); + const processExit = sinon.stub(process, 'exit').callsFake(() => { + throw processExitError; + }); + + expect(() => { + loadBin({ + '../src/cli/is-supported-node-version': isSupportedNodeVersion, + '../package.json': { version: '1.3.0' }, + '../src': { runComponents }, + }); + }).to.throw(processExitError); + + expect(isSupportedNodeVersion).to.have.been.calledOnceWithExactly(process.version); + expect(stderrWrite).to.have.been.calledOnceWithExactly( + 'Error: Serverless Framework Compose v1.3.0 does not support ' + + `Node.js ${process.version}. Please upgrade Node.js to the latest ` + + 'LTS release. Minimum supported version: v20.0.0.\n' + ); + expect(processExit).to.have.been.calledOnceWithExactly(1); + expect(runComponents.called).to.equal(false); + }); + + it('loads the runtime on supported Node versions', () => { + const isSupportedNodeVersion = sinon.stub().returns(true); + const runComponents = sinon.stub().resolves(); + const stderrWrite = sinon.stub(process.stderr, 'write'); + const processExit = sinon.stub(process, 'exit'); + + loadBin({ + '../src/cli/is-supported-node-version': isSupportedNodeVersion, + '../src': { runComponents }, + }); + + expect(isSupportedNodeVersion).to.have.been.calledOnceWithExactly(process.version); + expect(runComponents).to.have.been.calledOnceWithExactly(); + expect(stderrWrite.called).to.equal(false); + expect(processExit.called).to.equal(false); + }); + + it('rethrows async runtime failures on nextTick', () => { + const runtimeError = new Error('boom'); + const nextTick = sinon.stub(process, 'nextTick').callsFake((handler) => handler()); + + expect(() => { + loadBin({ + '../src/cli/is-supported-node-version': sinon.stub().returns(true), + '../src': { + runComponents: sinon.stub().returns({ + catch: (handler) => handler(runtimeError), + }), + }, + }); + }).to.throw(runtimeError); + + expect(nextTick).to.have.been.calledOnce; + }); +}); diff --git a/test/unit/components/framework/index.test.js b/test/unit/components/framework/index.test.js index f1de072..8299c88 100644 --- a/test/unit/components/framework/index.test.js +++ b/test/unit/components/framework/index.test.js @@ -232,6 +232,46 @@ describe('test/unit/components/framework/index.test.js', () => { expect(spawnStub.getCall(0).args[2].env.SLS_COMPOSE).to.equal('1'); }); + it('passes through serverless logging env vars to child processes', async () => { + const originalLogLevel = process.env.SLS_LOG_LEVEL; + const originalLogDebug = process.env.SLS_LOG_DEBUG; + + try { + process.env.SLS_LOG_LEVEL = 'info'; + process.env.SLS_LOG_DEBUG = 'aws'; + + const spawnStub = sinon.stub().returns({ + on: (arg, cb) => { + if (arg === 'close') cb(0); + }, + stdout: { + on: (arg, cb) => { + const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output'; + if (arg === 'data') cb(data); + }, + }, + kill: () => {}, + }); + const FrameworkComponent = proxyquire('../../../../components/framework/index.js', { + 'cross-spawn': spawnStub, + }); + + const context = await getContext(); + const component = new FrameworkComponent('some-id', context, { path: 'path' }); + context.state.detectedFrameworkVersion = '9.9.9'; + await component.refreshOutputs(); + + expect(spawnStub).to.be.calledOnce; + expect(spawnStub.getCall(0).args[2].env.SLS_LOG_LEVEL).to.equal('info'); + expect(spawnStub.getCall(0).args[2].env.SLS_LOG_DEBUG).to.equal('aws'); + } finally { + if (originalLogLevel == null) delete process.env.SLS_LOG_LEVEL; + else process.env.SLS_LOG_LEVEL = originalLogLevel; + if (originalLogDebug == null) delete process.env.SLS_LOG_DEBUG; + else process.env.SLS_LOG_DEBUG = originalLogDebug; + } + }); + it('correctly handles refresh-outputs with malformed info outputs', async () => { const spawnStub = sinon.stub().returns({ on: (arg, cb) => { diff --git a/test/unit/src/Context.test.js b/test/unit/src/Context.test.js new file mode 100644 index 0000000..fa8fa94 --- /dev/null +++ b/test/unit/src/Context.test.js @@ -0,0 +1,33 @@ +'use strict'; + +const expect = require('chai').expect; +const stripAnsi = require('strip-ansi'); + +const Context = require('../../../src/Context'); +const readStream = require('../read-stream'); + +describe('test/unit/src/Context.test.js', () => { + const contextConfig = { + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }; + + it('does not render empty or invalid outputs', async () => { + const context = new Context(contextConfig); + + context.renderOutputs(null); + context.renderOutputs({}); + + expect(await readStream(context.output.stdout)).to.equal(''); + }); + + it('renders outputs through the configured output writer', async () => { + const context = new Context(contextConfig); + + context.renderOutputs({ value: 1 }); + + expect(stripAnsi(await readStream(context.output.stdout))).to.equal('value: 1\n'); + }); +}); diff --git a/test/unit/src/cli/format-output.test.js b/test/unit/src/cli/format-output.test.js new file mode 100644 index 0000000..98f71b2 --- /dev/null +++ b/test/unit/src/cli/format-output.test.js @@ -0,0 +1,176 @@ +'use strict'; + +const expect = require('chai').expect; +const stripAnsi = require('strip-ansi'); + +const colors = require('../../../../src/cli/colors'); +const formatOutput = require('../../../../src/cli/format-output'); + +describe('test/unit/src/cli/format-output.test.js', () => { + it('renders nested outputs in the current CLI format', () => { + const result = formatOutput({ + service: { + url: 'https://example.com', + enabled: true, + }, + values: [1, 'text', { nested: false }], + emptyArr: [], + emptyObject: {}, + description: 'line1\nline2', + }); + + expect(stripAnsi(result)).to.equal( + [ + 'service: ', + ' url: https://example.com', + ' enabled: true', + 'values: ', + ' - 1', + ' - text', + ' - ', + ' nested: false', + 'emptyArr: (empty array)', + 'emptyObject: ', + 'description: ', + ' """', + ' line1', + ' line2', + ' """', + '', + ].join('\n') + ); + }); + + it('preserves sparse arrays and edge scalar values', () => { + const holes = []; + holes[1] = 1; + + const result = formatOutput({ + holes, + nullValue: null, + undefinedValue: undefined, + nested: [[{ x: 1 }]], + bools: [true, false], + }); + + expect(stripAnsi(result)).to.equal( + [ + 'holes: ', + ' - undefined', + ' - 1', + 'nullValue: null', + 'undefinedValue: undefined', + 'nested: ', + ' - ', + ' - ', + ' x: 1', + 'bools: ', + ' - true', + ' - false', + '', + ].join('\n') + ); + }); + + it('renders nested empty collections and multiline strings in arrays', () => { + const result = formatOutput({ + items: [[], {}], + nested: { + arr: [[]], + obj: { empty: {} }, + }, + multi: ['line1\nline2'], + }); + + expect(stripAnsi(result)).to.equal( + [ + 'items: ', + ' - (empty array)', + ' - ', + 'nested: ', + ' arr: ', + ' - (empty array)', + ' obj: ', + ' empty: ', + 'multi: ', + ' - ', + ' """', + ' line1', + ' line2', + ' """', + '', + ].join('\n') + ); + }); + + it('caps nested rendering depth consistently across objects and arrays', () => { + const result = formatOutput({ + a: { + b: { + c: { + d: { + e: 1, + }, + }, + }, + }, + arr: [[[[{ x: 1 }]]]], + multiline: { + a: { + b: { + c: 'line1\nline2', + }, + }, + }, + multilineArr: [[[['line1\nline2']]]], + }); + + expect(stripAnsi(result)).to.equal( + [ + 'a: ', + ' b: ', + ' c: ', + ' d: (max depth reached)', + 'arr: ', + ' - ', + ' - ', + ' - (max depth reached)', + 'multiline: ', + ' a: ', + ' b: ', + ' c: (max depth reached)', + 'multilineArr: ', + ' - ', + ' - ', + ' - (max depth reached)', + '', + ].join('\n') + ); + }); + + it('applies the expected CLI colors', () => { + const result = formatOutput({ + count: 1, + enabled: true, + disabled: false, + missing: null, + unknown: undefined, + text: 'value', + items: [1], + }); + + expect(result).to.equal( + [ + `${colors.gray('count: ')}${colors.white('1')}`, + `${colors.gray('enabled: ')}${colors.white('true')}`, + `${colors.gray('disabled: ')}${colors.white('false')}`, + `${colors.gray('missing: ')}${colors.gray('null')}`, + `${colors.gray('unknown: ')}${colors.gray('undefined')}`, + `${colors.gray('text: ')}value`, + `${colors.gray('items: ')}`, + `${colors.gray(' - ')}${colors.white('1')}`, + '', + ].join('\n') + ); + }); +}); diff --git a/test/unit/src/cli/is-supported-node-version.test.js b/test/unit/src/cli/is-supported-node-version.test.js new file mode 100644 index 0000000..2c22a0b --- /dev/null +++ b/test/unit/src/cli/is-supported-node-version.test.js @@ -0,0 +1,19 @@ +'use strict'; + +const expect = require('chai').expect; + +const isSupportedNodeVersion = require('../../../../src/cli/is-supported-node-version'); + +describe('test/unit/src/cli/is-supported-node-version.test.js', () => { + it('rejects Node 18', () => { + expect(isSupportedNodeVersion('v18.20.0')).to.equal(false); + }); + + it('accepts Node 20', () => { + expect(isSupportedNodeVersion('v20.0.0')).to.equal(true); + }); + + it('accepts Node 22', () => { + expect(isSupportedNodeVersion('v22.1.0')).to.equal(true); + }); +}); diff --git a/test/unit/src/components-service.test.js b/test/unit/src/components-service.test.js index 1b43213..64c0386 100644 --- a/test/unit/src/components-service.test.js +++ b/test/unit/src/components-service.test.js @@ -1,12 +1,17 @@ 'use strict'; +const chai = require('chai'); const path = require('path'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); const ComponentsService = require('../../../src/ComponentsService'); const Context = require('../../../src/Context'); const stripAnsi = require('strip-ansi'); const readStream = require('../read-stream'); -const expect = require('chai').expect; +chai.use(require('chai-as-promised')); + +const expect = chai.expect; const frameworkComponentPath = path.dirname( require.resolve('../../../components/framework/index.js') @@ -120,6 +125,244 @@ describe('test/unit/src/components-service.test.js', () => { ); }); + it('rejects circular component dependencies', async () => { + const configuration = { + name: 'cycle-test', + services: { + resources: { + component: '@foo/resources', + path: 'resources', + dependsOn: 'consumer', + }, + consumer: { + component: '@foo/consumer', + path: 'consumer', + dependsOn: 'resources', + }, + }, + }; + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + + const localComponentsService = new ComponentsService(context, configuration, {}); + + await expect(localComponentsService.init()).to.eventually.be.rejected.and.have.property( + 'code', + 'CIRCULAR_GRAPH_DEPENDENCIES' + ); + }); + + it('deploys dependencies before dependents', async () => { + const order = []; + const loadComponent = sinon.stub().callsFake(async ({ alias }) => ({ + async deploy() { + order.push(alias); + }, + })); + const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', { + './load': { loadComponent }, + }); + + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + context.stateStorage.readComponentsOutputs = async () => ({}); + + const configuration = { + services: { + foundation: { + component: '@foo/foundation', + path: 'foundation', + }, + api: { + component: '@foo/api', + path: 'api', + dependsOn: 'foundation', + }, + app: { + component: '@foo/app', + path: 'app', + dependsOn: 'api', + }, + }, + }; + + const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {}); + await localComponentsService.init(); + await localComponentsService.deploy(); + + expect(order).to.deep.equal(['foundation', 'api', 'app']); + }); + + it('removes dependents before dependencies', async () => { + const order = []; + const loadComponent = sinon.stub().callsFake(async ({ alias }) => ({ + async remove() { + order.push(alias); + }, + })); + const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', { + './load': { loadComponent }, + }); + + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + context.stateStorage.readComponentsOutputs = async () => ({}); + context.stateStorage.removeState = async () => {}; + + const configuration = { + services: { + foundation: { + component: '@foo/foundation', + path: 'foundation', + }, + api: { + component: '@foo/api', + path: 'api', + dependsOn: 'foundation', + }, + app: { + component: '@foo/app', + path: 'app', + dependsOn: 'api', + }, + }, + }; + + const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {}); + await localComponentsService.init(); + await localComponentsService.remove(); + + expect(order).to.deep.equal(['app', 'api', 'foundation']); + }); + + it('deploys branched DAGs in dependency order', async () => { + const order = []; + const loadComponent = sinon.stub().callsFake(async ({ alias }) => ({ + async deploy() { + order.push(alias); + }, + })); + const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', { + './load': { loadComponent }, + }); + + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + context.stateStorage.readComponentsOutputs = async () => ({}); + + const configuration = { + services: { + foundation: { + component: '@foo/foundation', + path: 'foundation', + }, + api: { + component: '@foo/api', + path: 'api', + dependsOn: 'foundation', + }, + worker: { + component: '@foo/worker', + path: 'worker', + dependsOn: 'foundation', + }, + app: { + component: '@foo/app', + path: 'app', + dependsOn: ['api', 'worker'], + }, + }, + }; + + const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {}); + await localComponentsService.init(); + await localComponentsService.deploy(); + + const foundationIndex = order.indexOf('foundation'); + const apiIndex = order.indexOf('api'); + const workerIndex = order.indexOf('worker'); + const appIndex = order.indexOf('app'); + + expect(foundationIndex).to.be.lessThan(apiIndex); + expect(foundationIndex).to.be.lessThan(workerIndex); + expect(apiIndex).to.be.lessThan(appIndex); + expect(workerIndex).to.be.lessThan(appIndex); + }); + + it('skips downstream graph layers after a failure', async () => { + const order = []; + const loadComponent = sinon.stub().callsFake(async ({ alias, context }) => ({ + async deploy() { + context.progresses.start(alias, 'deploying'); + order.push(alias); + if (alias === 'api') { + throw new Error('boom'); + } + context.progresses.success(alias, 'deployed'); + }, + })); + const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', { + './load': { loadComponent }, + }); + + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + context.stateStorage.readComponentsOutputs = async () => ({}); + + const configuration = { + services: { + foundation: { + component: '@foo/foundation', + path: 'foundation', + }, + api: { + component: '@foo/api', + path: 'api', + dependsOn: 'foundation', + }, + app: { + component: '@foo/app', + path: 'app', + dependsOn: 'api', + }, + }, + }; + + const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {}); + await localComponentsService.init(); + await localComponentsService.deploy(); + + expect(order).to.deep.equal(['foundation', 'api']); + expect(context.componentCommandsOutcomes.foundation).to.equal('success'); + expect(context.componentCommandsOutcomes.api).to.equal('failure'); + expect(context.componentCommandsOutcomes.app).to.equal('skip'); + }); + it('correctly handles outputs command', async () => { const configuration = { name: 'test-service', @@ -161,7 +404,6 @@ describe('test/unit/src/components-service.test.js', () => { await componentsService.outputs(); expect(stripAnsi(await readStream(context.output.stdout))).to.equal( [ - '', 'resources: ', ' somethingelse: 123', 'anotherservice: ', @@ -206,7 +448,7 @@ describe('test/unit/src/components-service.test.js', () => { await componentsService.outputs({ componentName: 'resources' }); expect(stripAnsi(await readStream(context.output.stdout))).to.equal( - ['', 'somethingelse: 123', ''].join('\n') + ['somethingelse: 123', ''].join('\n') ); }); }); diff --git a/test/unit/src/load.test.js b/test/unit/src/load.test.js new file mode 100644 index 0000000..2f8ec2c --- /dev/null +++ b/test/unit/src/load.test.js @@ -0,0 +1,34 @@ +'use strict'; + +const path = require('path'); +const stripAnsi = require('strip-ansi'); +const expect = require('chai').expect; + +const Context = require('../../../src/Context'); +const readStream = require('../read-stream'); +const { loadComponent } = require('../../../src/load'); + +describe('test/unit/src/load.test.js', () => { + it('supports in-process components using ComponentContext logging APIs', async () => { + const context = new Context({ + root: process.cwd(), + stage: 'dev', + disableIO: true, + configuration: {}, + }); + await context.init(); + context.output.enableVerbose(); + + const component = await loadComponent({ + context, + path: path.resolve(__dirname, '../../fixtures/local-component.js'), + alias: 'worker', + inputs: { message: 'hello' }, + }); + + await component.emitLogs(); + + expect(stripAnsi(await readStream(context.output.stdout))).to.equal('worker › hello\n'); + expect(stripAnsi(await readStream(context.output.stderr))).to.equal('worker › verbose hello\n'); + }); +}); diff --git a/test/unit/src/state/S3StateStorage.test.js b/test/unit/src/state/S3StateStorage.test.js index 3b06877..35586d8 100644 --- a/test/unit/src/state/S3StateStorage.test.js +++ b/test/unit/src/state/S3StateStorage.test.js @@ -1,6 +1,7 @@ 'use strict'; const chai = require('chai'); +const proxyquire = require('proxyquire'); const sinon = require('sinon'); const stream = require('stream'); @@ -88,4 +89,26 @@ describe('test/unit/src/state/S3StateStorage.test.js', () => { Key: stateKey, }); }); + + it('passes region and credentials into the S3 client constructor', () => { + const S3 = sinon.stub().returns({}); + const S3StateStorageWithStubbedClient = proxyquire + .noCallThru() + .load('../../../../src/state/S3StateStorage', { + '@aws-sdk/client-s3': { S3 }, + }); + + const stateStorage = new S3StateStorageWithStubbedClient({ + bucketName, + stateKey, + region: 'eu-central-1', + credentials: 'creds', + }); + + expect(stateStorage).to.be.instanceOf(S3StateStorageWithStubbedClient); + expect(S3).to.have.been.calledOnceWithExactly({ + region: 'eu-central-1', + credentials: 'creds', + }); + }); }); diff --git a/test/unit/src/state/get-s3-state-storage-from-config.test.js b/test/unit/src/state/get-s3-state-storage-from-config.test.js new file mode 100644 index 0000000..0027c1e --- /dev/null +++ b/test/unit/src/state/get-s3-state-storage-from-config.test.js @@ -0,0 +1,147 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + it('uses us-east-1 for compose-managed buckets', async () => { + const getStateBucketName = sinon.stub().resolves('managed-bucket'); + const getConfiguredStateBucketName = sinon.stub().returns(null); + const getStateBucketRegion = sinon.stub(); + const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' }); + + class S3StateStorage { + constructor(config) { + this.config = config; + } + } + + const getS3StateStorageFromConfig = proxyquire + .noCallThru() + .load('../../../../src/state/get-s3-state-storage-from-config', { + '../utils/aws': { getAwsClientConfig }, + './S3StateStorage': S3StateStorage, + './utils/get-configured-state-bucket-name': getConfiguredStateBucketName, + './utils/get-state-bucket-name': getStateBucketName, + './utils/get-state-bucket-region': getStateBucketRegion, + }); + + const stateStorage = await getS3StateStorageFromConfig( + { backend: 's3', prefix: 'custom', profile: 'team' }, + { stage: 'prod' } + ); + + expect(getStateBucketName).to.have.been.calledOnce; + expect(getConfiguredStateBucketName).to.have.been.calledOnce; + expect(getStateBucketRegion.called).to.equal(false); + expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ + profile: 'team', + region: 'us-east-1', + }); + expect(stateStorage.config).to.deep.equal({ + bucketName: 'managed-bucket', + stateKey: 'custom/prod/state.json', + region: 'us-east-1', + credentials: 'creds', + }); + }); + + it('resolves region for configured external buckets', async () => { + const stateConfiguration = { + backend: 's3', + externalBucket: 'provided-bucket', + profile: 'team', + }; + const getStateBucketName = sinon.stub().resolves('provided-bucket'); + const getConfiguredStateBucketName = sinon.stub().returns('provided-bucket'); + const getStateBucketRegion = sinon.stub().resolves('eu-central-1'); + const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' }); + + class S3StateStorage { + constructor(config) { + this.config = config; + } + } + + const getS3StateStorageFromConfig = proxyquire + .noCallThru() + .load('../../../../src/state/get-s3-state-storage-from-config', { + '../utils/aws': { getAwsClientConfig }, + './S3StateStorage': S3StateStorage, + './utils/get-configured-state-bucket-name': getConfiguredStateBucketName, + './utils/get-state-bucket-name': getStateBucketName, + './utils/get-state-bucket-region': getStateBucketRegion, + }); + + const stateStorage = await getS3StateStorageFromConfig(stateConfiguration, { stage: 'dev' }); + + expect(getStateBucketRegion).to.have.been.calledOnceWithExactly( + 'provided-bucket', + stateConfiguration + ); + expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ + profile: 'team', + region: 'eu-central-1', + }); + expect(stateStorage.config).to.deep.equal({ + bucketName: 'provided-bucket', + stateKey: 'dev/state.json', + region: 'eu-central-1', + credentials: 'creds', + }); + }); + + it('resolves region for configured existing buckets', async () => { + const stateConfiguration = { + backend: 's3', + existingBucket: 'provided-bucket', + profile: 'team', + }; + const getStateBucketName = sinon.stub().resolves('provided-bucket'); + const getConfiguredStateBucketName = sinon.stub().returns('provided-bucket'); + const getStateBucketRegion = sinon.stub().resolves('eu-central-1'); + const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' }); + + class S3StateStorage { + constructor(config) { + this.config = config; + } + } + + const getS3StateStorageFromConfig = proxyquire + .noCallThru() + .load('../../../../src/state/get-s3-state-storage-from-config', { + '../utils/aws': { getAwsClientConfig }, + './S3StateStorage': S3StateStorage, + './utils/get-configured-state-bucket-name': getConfiguredStateBucketName, + './utils/get-state-bucket-name': getStateBucketName, + './utils/get-state-bucket-region': getStateBucketRegion, + }); + + const stateStorage = await getS3StateStorageFromConfig(stateConfiguration, { stage: 'dev' }); + + expect(getStateBucketRegion).to.have.been.calledOnceWithExactly( + 'provided-bucket', + stateConfiguration + ); + expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ + profile: 'team', + region: 'eu-central-1', + }); + expect(stateStorage.config).to.deep.equal({ + bucketName: 'provided-bucket', + stateKey: 'dev/state.json', + region: 'eu-central-1', + credentials: 'creds', + }); + }); +}); diff --git a/test/unit/src/state/utils/get-configured-state-bucket-name.test.js b/test/unit/src/state/utils/get-configured-state-bucket-name.test.js new file mode 100644 index 0000000..5822bf6 --- /dev/null +++ b/test/unit/src/state/utils/get-configured-state-bucket-name.test.js @@ -0,0 +1,39 @@ +'use strict'; + +const expect = require('chai').expect; + +const getConfiguredStateBucketName = require('../../../../../src/state/utils/get-configured-state-bucket-name'); + +describe('test/unit/src/state/utils/get-configured-state-bucket-name.test.js', () => { + it('returns existingBucket when provided', () => { + expect(getConfiguredStateBucketName({ existingBucket: 'existing-bucket' })).to.equal( + 'existing-bucket' + ); + }); + + it('supports externalBucket as a compatibility alias', () => { + expect(getConfiguredStateBucketName({ externalBucket: 'external-bucket' })).to.equal( + 'external-bucket' + ); + }); + + it('allows both configured bucket names when they match', () => { + expect( + getConfiguredStateBucketName({ + existingBucket: 'shared-bucket', + externalBucket: 'shared-bucket', + }) + ).to.equal('shared-bucket'); + }); + + it('rejects conflicting configured bucket names', () => { + expect(() => + getConfiguredStateBucketName({ + existingBucket: 'first-bucket', + externalBucket: 'second-bucket', + }) + ) + .to.throw() + .and.have.property('code', 'INVALID_STATE_CONFIGURATION'); + }); +}); diff --git a/test/unit/src/state/utils/get-state-bucket-name.test.js b/test/unit/src/state/utils/get-state-bucket-name.test.js index 7acaf27..d805880 100644 --- a/test/unit/src/state/utils/get-state-bucket-name.test.js +++ b/test/unit/src/state/utils/get-state-bucket-name.test.js @@ -2,6 +2,8 @@ const chai = require('chai'); const { mockClient } = require('aws-sdk-client-mock'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); const { CloudFormationClient, DescribeStackResourceCommand, @@ -12,6 +14,7 @@ const { const getStateBucketName = require('../../../../../src/state/utils/get-state-bucket-name'); const Context = require('../../../../../src/Context'); +chai.use(require('chai-as-promised')); chai.use(require('sinon-chai')); const expect = chai.expect; @@ -41,6 +44,15 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => { expect(await getStateBucketName(configuration, context)).to.equal('existing'); }); + it('supports externalBucket as a compatibility alias', async () => { + const configuration = { + backend: 's3', + externalBucket: 'external', + }; + + expect(await getStateBucketName(configuration, context)).to.equal('external'); + }); + it('resolves already existing bucket name provisioned by compose', async () => { const configuration = { backend: 's3' }; cfMock @@ -93,4 +105,36 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => { getStateBucketName(configuration, context) ).to.be.eventually.rejected.and.have.property('code', 'CANNOT_DEPLOY_S3_REMOTE_STATE_STACK'); }); + + it('uses profile-aware AWS config for CloudFormation access', async () => { + const describeStackResource = sinon.stub().resolves({ + StackResourceDetail: { PhysicalResourceId: 'fromcf' }, + }); + const CloudFormation = sinon.stub().callsFake(() => ({ + describeStackResource, + })); + const getAwsClientConfig = sinon.stub().returns({ + region: 'us-east-1', + credentials: 'creds', + }); + + const getStateBucketNameWithStubs = proxyquire + .noCallThru() + .load('../../../../../src/state/utils/get-state-bucket-name', { + '@aws-sdk/client-cloudformation': { CloudFormation }, + '../../utils/aws': { getAwsClientConfig }, + }); + + expect(await getStateBucketNameWithStubs({ backend: 's3', profile: 'team' }, context)).to.equal( + 'fromcf' + ); + expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ + profile: 'team', + region: 'us-east-1', + }); + expect(CloudFormation).to.have.been.calledOnceWithExactly({ + region: 'us-east-1', + credentials: 'creds', + }); + }); }); diff --git a/test/unit/src/state/utils/get-state-bucket-region.test.js b/test/unit/src/state/utils/get-state-bucket-region.test.js index 4f15520..c931532 100644 --- a/test/unit/src/state/utils/get-state-bucket-region.test.js +++ b/test/unit/src/state/utils/get-state-bucket-region.test.js @@ -3,9 +3,12 @@ const chai = require('chai'); const { mockClient } = require('aws-sdk-client-mock'); const { S3Client, GetBucketLocationCommand } = require('@aws-sdk/client-s3'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); const getStateBucketRegion = require('../../../../../src/state/utils/get-state-bucket-region'); +chai.use(require('chai-as-promised')); chai.use(require('sinon-chai')); const expect = chai.expect; @@ -32,6 +35,11 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => { expect(await getStateBucketRegion(bucketName)).to.equal('eu-central-1'); }); + it('normalizes the legacy `EU` region alias', async () => { + s3Mock.on(GetBucketLocationCommand).resolves({ LocationConstraint: 'EU' }); + expect(await getStateBucketRegion(bucketName)).to.equal('eu-west-1'); + }); + it('rejects when bucket cannot be found', async () => { const bucketDoesNotExistError = new Error('No such bucket'); bucketDoesNotExistError.Code = 'NoSuchBucket'; @@ -61,4 +69,29 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => { 'GENERIC_CANNOT_ACCESS_PROVIDED_REMOTE_STATE_BUCKET' ); }); + + it('uses profile-aware AWS client config for bucket lookups', async () => { + const getBucketLocation = sinon.stub().resolves({ LocationConstraint: 'eu-central-1' }); + const S3 = sinon.stub().callsFake(() => ({ getBucketLocation })); + const getAwsClientConfig = sinon.stub().returns({ + region: 'us-east-1', + credentials: 'creds', + }); + + const getStateBucketRegionWithStubs = proxyquire + .noCallThru() + .load('../../../../../src/state/utils/get-state-bucket-region', { + '@aws-sdk/client-s3': { S3 }, + '../../utils/aws': { getAwsClientConfig }, + }); + + expect(await getStateBucketRegionWithStubs(bucketName, { profile: 'team' })).to.equal( + 'eu-central-1' + ); + expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ + profile: 'team', + region: 'us-east-1', + }); + expect(S3).to.have.been.calledOnceWithExactly({ region: 'us-east-1', credentials: 'creds' }); + }); }); diff --git a/test/unit/src/utils/aws/default-provider.test.js b/test/unit/src/utils/aws/default-provider.test.js new file mode 100644 index 0000000..a5431d2 --- /dev/null +++ b/test/unit/src/utils/aws/default-provider.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/aws/default-provider.test.js', () => { + const getStubs = () => { + const envProvider = sinon.stub().returns('env-provider'); + const ssoProvider = sinon.stub().returns('sso-provider'); + const iniProvider = sinon.stub().returns('ini-provider'); + const processProvider = sinon.stub().returns('process-provider'); + const tokenProvider = sinon.stub().returns('token-provider'); + const remoteProvider = sinon.stub().returns('remote-provider'); + const chain = sinon.stub().callsFake((...providers) => providers); + const memoize = sinon.stub().callsFake((provider) => provider); + + const defaultProvider = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/provider-chain/default-provider', { + '@aws-sdk/credential-provider-env': { fromEnv: envProvider }, + '@aws-sdk/credential-provider-ini': { fromIni: iniProvider }, + '@aws-sdk/credential-provider-process': { fromProcess: processProvider }, + '@aws-sdk/credential-provider-sso': { fromSSO: ssoProvider }, + '@aws-sdk/credential-provider-web-identity': { fromTokenFile: tokenProvider }, + '@aws-sdk/property-provider': { + chain, + memoize, + CredentialsProviderError: class CredentialsProviderError extends Error {}, + }, + './remote-provider': remoteProvider, + }); + + return { + defaultProvider, + envProvider, + ssoProvider, + iniProvider, + processProvider, + tokenProvider, + remoteProvider, + chain, + memoize, + }; + }; + + afterEach(() => { + sinon.restore(); + }); + + it('keeps env credentials first when no explicit profile is provided', () => { + const { + defaultProvider, + envProvider, + ssoProvider, + iniProvider, + processProvider, + tokenProvider, + remoteProvider, + chain, + memoize, + } = getStubs(); + const init = { region: 'us-east-1' }; + + const provider = defaultProvider(init); + + expect(provider).to.deep.equal(chain.firstCall.args); + expect(envProvider).to.have.been.calledOnceWithExactly(); + expect(ssoProvider).to.have.been.calledOnceWithExactly(init); + expect(iniProvider).to.have.been.calledOnceWithExactly(init); + expect(processProvider).to.have.been.calledOnceWithExactly(init); + expect(tokenProvider).to.have.been.calledOnceWithExactly(init); + expect(remoteProvider).to.have.been.calledOnceWithExactly(init); + expect(chain.firstCall.args.slice(0, 6)).to.deep.equal([ + 'env-provider', + 'sso-provider', + 'ini-provider', + 'process-provider', + 'token-provider', + 'remote-provider', + ]); + expect(memoize).to.have.been.calledOnce; + }); + + it('skips env credentials when an explicit profile is provided', () => { + const { defaultProvider, envProvider, chain } = getStubs(); + + defaultProvider({ profile: 'team' }); + + expect(envProvider.called).to.equal(false); + expect(chain.firstCall.args[0]).to.equal('sso-provider'); + }); +}); diff --git a/test/unit/src/utils/aws/from-node-provider-chain.test.js b/test/unit/src/utils/aws/from-node-provider-chain.test.js new file mode 100644 index 0000000..694f997 --- /dev/null +++ b/test/unit/src/utils/aws/from-node-provider-chain.test.js @@ -0,0 +1,45 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/aws/from-node-provider-chain.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + it('injects default role assumers derived from clientConfig', () => { + const getDefaultRoleAssumer = sinon.stub().returns('roleAssumer'); + const getDefaultRoleAssumerWithWebIdentity = sinon.stub().returns('webIdentityAssumer'); + const defaultProvider = sinon.stub().returns('provider'); + + const fromNodeProviderChain = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/provider-chain/from-node-provider-chain', { + '@aws-sdk/client-sts': { + getDefaultRoleAssumer, + getDefaultRoleAssumerWithWebIdentity, + }, + './default-provider': defaultProvider, + }); + + expect( + fromNodeProviderChain({ profile: 'team', clientConfig: { region: 'us-east-1' } }) + ).to.equal('provider'); + expect(getDefaultRoleAssumer).to.have.been.calledOnceWithExactly({ region: 'us-east-1' }); + expect(getDefaultRoleAssumerWithWebIdentity).to.have.been.calledOnceWithExactly({ + region: 'us-east-1', + }); + expect(defaultProvider).to.have.been.calledOnceWithExactly({ + profile: 'team', + clientConfig: { region: 'us-east-1' }, + roleAssumer: 'roleAssumer', + roleAssumerWithWebIdentity: 'webIdentityAssumer', + }); + }); +}); diff --git a/test/unit/src/utils/aws/get-client-config.test.js b/test/unit/src/utils/aws/get-client-config.test.js new file mode 100644 index 0000000..d07211b --- /dev/null +++ b/test/unit/src/utils/aws/get-client-config.test.js @@ -0,0 +1,33 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/aws/get-client-config.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns the region and credentials together', () => { + const getCredentialProvider = sinon.stub().returns('creds'); + const getClientConfig = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/get-client-config', { + './get-credential-provider': getCredentialProvider, + }); + + expect(getClientConfig({ profile: 'team', region: 'eu-central-1' })).to.deep.equal({ + region: 'eu-central-1', + credentials: 'creds', + }); + expect(getCredentialProvider).to.have.been.calledOnceWithExactly({ + profile: 'team', + region: 'eu-central-1', + }); + }); +}); diff --git a/test/unit/src/utils/aws/get-credential-provider.test.js b/test/unit/src/utils/aws/get-credential-provider.test.js new file mode 100644 index 0000000..993360a --- /dev/null +++ b/test/unit/src/utils/aws/get-credential-provider.test.js @@ -0,0 +1,68 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/aws/get-credential-provider.test.js', () => { + let originalAwsProfile; + let originalAwsDefaultProfile; + + beforeEach(() => { + originalAwsProfile = process.env.AWS_PROFILE; + originalAwsDefaultProfile = process.env.AWS_DEFAULT_PROFILE; + }); + + afterEach(() => { + if (originalAwsProfile == null) delete process.env.AWS_PROFILE; + else process.env.AWS_PROFILE = originalAwsProfile; + if (originalAwsDefaultProfile == null) delete process.env.AWS_DEFAULT_PROFILE; + else process.env.AWS_DEFAULT_PROFILE = originalAwsDefaultProfile; + sinon.restore(); + }); + + it('falls back to AWS_DEFAULT_PROFILE and forwards the region to the provider chain', () => { + const credentialProvider = sinon.stub().returns('provider'); + delete process.env.AWS_PROFILE; + process.env.AWS_DEFAULT_PROFILE = 'default-profile'; + + const getCredentialProvider = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/get-credential-provider', { + './provider-chain/from-node-provider-chain': credentialProvider, + }); + + expect(getCredentialProvider({ profile: 'custom-profile', region: 'eu-central-1' })).to.equal( + 'provider' + ); + expect(process.env.AWS_PROFILE).to.equal('default-profile'); + expect(credentialProvider).to.have.been.calledOnceWithExactly({ + profile: 'custom-profile', + clientConfig: { region: 'eu-central-1' }, + }); + }); + + it('does not override an existing AWS_PROFILE value', () => { + const credentialProvider = sinon.stub().returns('provider'); + process.env.AWS_PROFILE = 'already-set'; + process.env.AWS_DEFAULT_PROFILE = 'default-profile'; + + const getCredentialProvider = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/get-credential-provider', { + './provider-chain/from-node-provider-chain': credentialProvider, + }); + + getCredentialProvider({ region: 'us-east-1' }); + + expect(process.env.AWS_PROFILE).to.equal('already-set'); + expect(credentialProvider).to.have.been.calledOnceWithExactly({ + profile: undefined, + clientConfig: { region: 'us-east-1' }, + }); + }); +}); diff --git a/test/unit/src/utils/aws/remote-provider.test.js b/test/unit/src/utils/aws/remote-provider.test.js new file mode 100644 index 0000000..f283bb3 --- /dev/null +++ b/test/unit/src/utils/aws/remote-provider.test.js @@ -0,0 +1,110 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/aws/remote-provider.test.js', () => { + let originalRelativeUri; + let originalFullUri; + let originalImdsDisabled; + + beforeEach(() => { + originalRelativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; + originalFullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + originalImdsDisabled = process.env.AWS_EC2_METADATA_DISABLED; + }); + + afterEach(() => { + if (originalRelativeUri == null) delete process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; + else process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = originalRelativeUri; + if (originalFullUri == null) delete process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + else process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = originalFullUri; + if (originalImdsDisabled == null) delete process.env.AWS_EC2_METADATA_DISABLED; + else process.env.AWS_EC2_METADATA_DISABLED = originalImdsDisabled; + sinon.restore(); + }); + + const loadRemoteProvider = () => { + const containerProvider = sinon.stub().returns('container-provider'); + const instanceProvider = sinon.stub().returns('instance-provider'); + const CredentialsProviderError = class extends Error {}; + + const remoteProvider = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/provider-chain/remote-provider', { + '@aws-sdk/property-provider': { CredentialsProviderError }, + '@aws-sdk/credential-provider-imds': { + ENV_CMDS_FULL_URI: 'AWS_CONTAINER_CREDENTIALS_FULL_URI', + ENV_CMDS_RELATIVE_URI: 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', + fromContainerMetadata: containerProvider, + fromInstanceMetadata: instanceProvider, + }, + }); + + return { remoteProvider, containerProvider, instanceProvider }; + }; + + it('uses container metadata when ECS credential variables are present', () => { + const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/ecs'; + + expect(remoteProvider({ timeout: 5 })).to.equal('container-provider'); + expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); + expect(instanceProvider.called).to.equal(false); + }); + + it('uses container metadata when AWS_CONTAINER_CREDENTIALS_FULL_URI is present', () => { + const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = 'http://169.254.170.2/v2/credentials/test'; + + expect(remoteProvider({ timeout: 5 })).to.equal('container-provider'); + expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); + expect(instanceProvider.called).to.equal(false); + }); + + it('disables IMDS when AWS_EC2_METADATA_DISABLED is set', async () => { + const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); + process.env.AWS_EC2_METADATA_DISABLED = 'true'; + + await expect(remoteProvider({})()).to.be.eventually.rejectedWith( + 'EC2 Instance Metadata Service access disabled' + ); + expect(containerProvider.called).to.equal(false); + expect(instanceProvider.called).to.equal(false); + }); + + it('falls back to instance metadata when remote env vars are absent', () => { + const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); + + expect(remoteProvider({ timeout: 5 })).to.equal('instance-provider'); + expect(instanceProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); + expect(containerProvider.called).to.equal(false); + }); + + it('disables IMDS whenever AWS_EC2_METADATA_DISABLED is set', async () => { + const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); + process.env.AWS_EC2_METADATA_DISABLED = 'false'; + + await expect(remoteProvider({})()).to.be.eventually.rejectedWith( + 'EC2 Instance Metadata Service access disabled' + ); + expect(containerProvider.called).to.equal(false); + expect(instanceProvider.called).to.equal(false); + }); + + it('prefers container metadata over IMDS disable settings', () => { + const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/ecs'; + process.env.AWS_EC2_METADATA_DISABLED = 'true'; + + expect(remoteProvider({ timeout: 5 })).to.equal('container-provider'); + expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); + expect(instanceProvider.called).to.equal(false); + }); +}); diff --git a/test/unit/src/utils/serverless-utils/log-reporters/node.test.js b/test/unit/src/utils/serverless-utils/log-reporters/node.test.js new file mode 100644 index 0000000..1fd3818 --- /dev/null +++ b/test/unit/src/utils/serverless-utils/log-reporters/node.test.js @@ -0,0 +1,141 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/serverless-utils/log-reporters/node.test.js', () => { + let originalArgv; + let originalInteractiveSetup; + let originalCi; + let originalLogLevel; + let originalLogDebug; + + beforeEach(() => { + originalArgv = process.argv.slice(); + originalInteractiveSetup = process.env.SLS_INTERACTIVE_SETUP_ENABLE; + originalCi = process.env.CI; + originalLogLevel = process.env.SLS_LOG_LEVEL; + originalLogDebug = process.env.SLS_LOG_DEBUG; + }); + + afterEach(() => { + process.argv = originalArgv; + if (originalInteractiveSetup == null) delete process.env.SLS_INTERACTIVE_SETUP_ENABLE; + else process.env.SLS_INTERACTIVE_SETUP_ENABLE = originalInteractiveSetup; + if (originalCi == null) delete process.env.CI; + else process.env.CI = originalCi; + if (originalLogLevel == null) delete process.env.SLS_LOG_LEVEL; + else process.env.SLS_LOG_LEVEL = originalLogLevel; + if (originalLogDebug == null) delete process.env.SLS_LOG_DEBUG; + else process.env.SLS_LOG_DEBUG = originalLogDebug; + sinon.restore(); + }); + + const loadModule = (uniGlobalState = {}, overrides = {}) => { + const logReporter = overrides.logReporter || sinon.stub(); + const progressReporter = overrides.progressReporter || sinon.stub(); + const outputEmitter = overrides.outputEmitter || { on: sinon.stub() }; + const joinTextTokens = overrides.joinTextTokens || sinon.stub().returns('joined'); + + proxyquire + .noCallThru() + .load('../../../../../../src/utils/serverless-utils/log-reporters/node', { + 'uni-global': () => uniGlobalState, + '../lib/log-reporters/node/log-reporter': logReporter, + '../lib/log/get-output-reporter': { emitter: outputEmitter }, + '../lib/log/join-text-tokens': joinTextTokens, + 'log/levels': ['debug', 'info', 'notice'], + '../lib/log-reporters/node/style': {}, + '../lib/log-reporters/node/progress-reporter': progressReporter, + }); + + return { logReporter, progressReporter, outputEmitter, joinTextTokens }; + }; + + it('sets SLS_LOG_LEVEL=info when verbose mode is enabled', () => { + const uniGlobalState = {}; + delete process.env.SLS_LOG_LEVEL; + process.argv = ['node', 'compose', '--verbose']; + + const { logReporter, outputEmitter } = loadModule(uniGlobalState); + + expect(process.env.SLS_LOG_LEVEL).to.equal('info'); + expect(logReporter).to.have.been.calledOnceWithExactly({ + logLevelIndex: 1, + debugNamespaces: undefined, + }); + expect(uniGlobalState.logLevelIndex).to.equal(1); + expect(outputEmitter.on).to.have.been.calledOnce; + }); + + it('does not override debug logging with verbose mode', () => { + const uniGlobalState = {}; + process.env.SLS_LOG_LEVEL = 'debug'; + process.argv = ['node', 'compose', '--verbose']; + + const { logReporter } = loadModule(uniGlobalState); + + expect(process.env.SLS_LOG_LEVEL).to.equal('debug'); + expect(logReporter).to.have.been.calledOnceWithExactly({ + logLevelIndex: 0, + debugNamespaces: undefined, + }); + }); + + it('is idempotent when reporter setup already happened', () => { + const uniGlobalState = { logLevelIndex: 1 }; + process.argv = ['node', 'compose', '--verbose']; + + const { logReporter, progressReporter, outputEmitter } = loadModule(uniGlobalState); + + expect(logReporter.called).to.equal(false); + expect(progressReporter.called).to.equal(false); + expect(outputEmitter.on.called).to.equal(false); + }); + + it('maps debug namespaces from argv into SLS_LOG_DEBUG', () => { + const uniGlobalState = {}; + delete process.env.SLS_LOG_DEBUG; + process.argv = ['node', 'compose', '--debug=aws']; + + const { logReporter } = loadModule(uniGlobalState); + + expect(process.env.SLS_LOG_DEBUG).to.equal('aws'); + expect(logReporter).to.have.been.calledOnceWithExactly({ + logLevelIndex: 2, + debugNamespaces: 'aws', + }); + }); + + it('registers the progress reporter when interactive setup is enabled', () => { + const uniGlobalState = {}; + process.env.SLS_INTERACTIVE_SETUP_ENABLE = '1'; + + const { progressReporter } = loadModule(uniGlobalState); + + expect(progressReporter).to.have.been.calledOnceWithExactly({ logLevelIndex: 2 }); + expect(uniGlobalState.logIsInteractive).to.equal('1'); + }); + + it('writes text-mode output events through joinTextTokens', () => { + const handlers = new Map(); + const outputEmitter = { + on: sinon.stub().callsFake((eventName, handler) => { + handlers.set(eventName, handler); + }), + }; + const joinTextTokens = sinon.stub().returns('joined\n'); + const stdoutWrite = sinon.stub(process.stdout, 'write'); + + loadModule({}, { outputEmitter, joinTextTokens }); + handlers.get('write')({ mode: 'text', textTokens: ['first', 'second'] }); + + expect(joinTextTokens).to.have.been.calledOnceWithExactly(['first', 'second']); + expect(stdoutWrite).to.have.been.calledOnceWithExactly('joined\n'); + }); +}); diff --git a/test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js b/test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js new file mode 100644 index 0000000..b666dc1 --- /dev/null +++ b/test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js @@ -0,0 +1,108 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +chai.use(require('sinon-chai')); + +const expect = chai.expect; + +describe('test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js', () => { + afterEach(() => { + sinon.restore(); + }); + + const loadProgressReporter = () => { + const handlers = new Map(); + const cliProgressFooter = { + shouldAddProgressAnimationPrefix: false, + progressAnimationPrefixFrames: ['.', 'o'], + updateProgress: sinon.stub(), + }; + const progress = {}; + const log = { info: sinon.stub() }; + const joinTextTokens = sinon.stub().returns('deploying\n'); + const style = { + aside: sinon.stub().callsFake((value) => value), + noticeSymbol: sinon.stub().callsFake((value) => value), + }; + + const load = proxyquire + .noCallThru() + .load( + '../../../../../../../src/utils/serverless-utils/lib/log-reporters/node/progress-reporter', + { + 'cli-progress-footer': sinon.stub().returns(cliProgressFooter), + '../../log/get-progress-reporter': { + emitter: { + on: sinon.stub().callsFake((eventName, handler) => { + handlers.set(eventName, handler); + }), + }, + }, + '../../../log': { progress, log }, + '../../log/join-text-tokens': joinTextTokens, + './style': style, + } + ); + + return { load, handlers, cliProgressFooter, progress, log, joinTextTokens, style }; + }; + + it('logs main events once and clears the progress footer', () => { + const { load, handlers, cliProgressFooter, progress, log, joinTextTokens, style } = + loadProgressReporter(); + const setIntervalStub = sinon.stub(global, 'setInterval').returns(123); + const clearIntervalStub = sinon.stub(global, 'clearInterval'); + sinon.stub(Date, 'now').returns(1000); + + load({ logLevelIndex: 2 }); + handlers.get('update')({ + namespace: 'serverless', + name: 'main', + levelIndex: 2, + textTokens: ['deploying'], + options: { isMainEvent: true }, + }); + handlers.get('update')({ + namespace: 'serverless', + name: 'main', + levelIndex: 2, + textTokens: ['deploying'], + options: { isMainEvent: true }, + }); + progress.clear(); + + expect(joinTextTokens).to.have.been.calledTwice; + expect(joinTextTokens.firstCall).to.have.been.calledWithExactly([['deploying']]); + expect(log.info).to.have.been.calledOnceWithExactly('deploying'); + expect(setIntervalStub).to.have.been.calledOnce; + expect(style.aside).to.have.been.calledWithExactly('(0s)'); + expect(cliProgressFooter.updateProgress.firstCall).to.have.been.calledWithExactly([ + 'deploying (0s)', + ]); + expect(clearIntervalStub).to.have.been.calledOnceWithExactly(123); + expect(cliProgressFooter.updateProgress.lastCall).to.have.been.calledWithExactly(); + }); + + it('tracks and removes sub-progress items', () => { + const { load, handlers, cliProgressFooter, joinTextTokens } = loadProgressReporter(); + + load({ logLevelIndex: 2 }); + handlers.get('update')({ + namespace: 'serverless:plugin:aws', + name: 'deploy', + levelIndex: 2, + textTokens: ['deploying'], + options: null, + }); + handlers.get('remove')({ namespace: 'serverless:plugin:aws', name: 'deploy' }); + + expect(joinTextTokens).to.have.been.calledOnceWithExactly([['deploying']]); + expect(cliProgressFooter.updateProgress.firstCall).to.have.been.calledWithExactly([ + 'deploying', + ]); + expect(cliProgressFooter.updateProgress.secondCall).to.have.been.calledWithExactly([]); + }); +}); diff --git a/test/unit/src/utils/serverless-utils/log.test.js b/test/unit/src/utils/serverless-utils/log.test.js new file mode 100644 index 0000000..1974f90 --- /dev/null +++ b/test/unit/src/utils/serverless-utils/log.test.js @@ -0,0 +1,25 @@ +'use strict'; + +const { expect } = require('chai'); + +describe('test/unit/src/utils/serverless-utils/log.test.js', () => { + const logModule = require('../../../../../src/utils/serverless-utils/log'); + + afterEach(() => { + logModule.getPluginWriters.clear(); + }); + + it('sanitizes plugin names while preserving the original plugin name', () => { + const rawPluginName = '@Scope/Plugin Name'; + + expect(() => logModule.log.get('plugin').get(rawPluginName)).to.throw(TypeError); + + const writers = logModule.getPluginWriters(rawPluginName); + + expect(writers.log.namespace).to.equal('serverless:plugin:-scope-plugin-name'); + expect(writers.log.pluginName).to.equal(rawPluginName); + expect(writers).to.equal(logModule.getPluginWriters(rawPluginName)); + expect(typeof writers.writeText).to.equal('function'); + expect(typeof writers.progress.get('upload').notice).to.equal('function'); + }); +}); diff --git a/test/unit/src/utils/serverless-utils/structure.test.js b/test/unit/src/utils/serverless-utils/structure.test.js new file mode 100644 index 0000000..428ad79 --- /dev/null +++ b/test/unit/src/utils/serverless-utils/structure.test.js @@ -0,0 +1,32 @@ +'use strict'; + +const fs = require('fs').promises; +const path = require('path'); +const { expect } = require('chai'); + +const policy = require('../../../../../src/utils/serverless-utils/policy'); + +const collectFiles = async (rootDir, prefix = '') => { + const entries = await fs.readdir(path.join(rootDir, prefix), { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const relativePath = path.join(prefix, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFiles(rootDir, relativePath))); + } else { + files.push(relativePath.replaceAll(path.sep, '/')); + } + } + + return files.sort(); +}; + +describe('test/unit/src/utils/serverless-utils/structure.test.js', () => { + it('matches the allowlisted vendored tree exactly', async () => { + const rootDir = path.resolve(__dirname, '../../../../../src/utils/serverless-utils'); + const actual = await collectFiles(rootDir); + + expect(actual).to.deep.equal([...policy.vendoredPaths, ...policy.maintainerPaths].sort()); + }); +});